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/Cargo.lock b/Cargo.lock index 998e28779..11b6a6aef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "17.2.6" +version = "17.2.8" dependencies = [ "anyhow", "ast-grep-core", @@ -4968,7 +4968,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "17.2.6" +version = "17.2.8" dependencies = [ "async-trait", "libc", @@ -4980,7 +4980,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "17.2.6" +version = "17.2.8" dependencies = [ "anyhow", "arboard", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "17.2.6" +version = "17.2.8" dependencies = [ "anyhow", "brush-builtins", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "pi-voice" -version = "17.2.6" +version = "17.2.8" dependencies = [ "audiopus_sys", "bytes", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "pi-walker" -version = "17.2.6" +version = "17.2.8" dependencies = [ "dashmap", "globset", diff --git a/Cargo.toml b/Cargo.toml index c052acccf..4a084f620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/pi-*", "crates/vendor/*"] resolver = "3" [workspace.package] -version = "17.2.6" +version = "17.2.8" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/README.md b/README.md index fffd522d8..931eb9144 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ The most capable agent surface that ships. Continuously tuned by real-world use curl -fsSL https://omp.sh/install | sh ``` +> **Alpine / musl:** the prebuilt musl binary links `libstdc++`/`libgcc` dynamically, which stock Alpine does not ship. Install them first: `apk add libstdc++ libgcc`. + **Homebrew** ```sh @@ -606,6 +608,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..298c970da 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "17.2.6", + "version": "17.2.8", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -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:", @@ -39,13 +40,13 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "17.2.6", + "version": "17.2.8", "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": { @@ -63,11 +64,11 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "17.2.6", + "version": "17.2.8", "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-utils": "catalog:", - "arktype": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -77,7 +78,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "17.2.6", + "version": "17.2.8", "bin": { "omp": "src/cli.ts", }, @@ -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:", @@ -151,7 +152,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "17.2.6", + "version": "17.2.8", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "lru-cache": "catalog:", @@ -195,7 +196,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "17.2.6", + "version": "17.2.8", "bin": { "mnemopi": "src/cli.ts", }, @@ -222,15 +223,29 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "17.2.6", + "version": "17.2.8", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", }, }, + "packages/omptype": { + "name": "@oh-my-pi/omptype", + "version": "17.2.8", + "devDependencies": { + "@ark/attest": "0.56.3", + "@ark/schema": "0.56.2", + "@ark/util": "0.56.2", + "@sinclair/typebox": "^0.34.0", + "@types/bun": "catalog:", + "arkregex": "0.0.8", + "arktype": "2.2.3", + "zod": "catalog:", + }, + }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "17.2.6", + "version": "17.2.8", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -243,7 +258,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "17.2.6", + "version": "17.2.8", "bin": { "omp-stats": "./src/index.ts", }, @@ -270,7 +285,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "17.2.6", + "version": "17.2.8", "bin": { "omp-swarm": "src/cli.ts", }, @@ -286,7 +301,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "17.2.6", + "version": "17.2.8", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -325,7 +340,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "17.2.6", + "version": "17.2.8", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "handlebars": "catalog:", @@ -338,7 +353,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "17.2.6", + "version": "17.2.8", "devDependencies": { "@types/bun": "catalog:", }, @@ -379,18 +394,19 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.2.6", - "@oh-my-pi/omp-stats": "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", - "@oh-my-pi/pi-coding-agent": "17.2.6", - "@oh-my-pi/pi-mnemopi": "17.2.6", - "@oh-my-pi/pi-natives": "17.2.6", - "@oh-my-pi/pi-tui": "17.2.6", - "@oh-my-pi/pi-utils": "17.2.6", - "@oh-my-pi/pi-wire": "17.2.6", - "@oh-my-pi/snapcompact": "17.2.6", + "@oh-my-pi/hashline": "17.2.8", + "@oh-my-pi/omp-stats": "17.2.8", + "@oh-my-pi/omptype": "17.2.8", + "@oh-my-pi/pi-agent-core": "17.2.8", + "@oh-my-pi/pi-ai": "17.2.8", + "@oh-my-pi/pi-catalog": "17.2.8", + "@oh-my-pi/pi-coding-agent": "17.2.8", + "@oh-my-pi/pi-mnemopi": "17.2.8", + "@oh-my-pi/pi-natives": "17.2.8", + "@oh-my-pi/pi-tui": "17.2.8", + "@oh-my-pi/pi-utils": "17.2.8", + "@oh-my-pi/pi-wire": "17.2.8", + "@oh-my-pi/snapcompact": "17.2.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -413,7 +429,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", @@ -462,6 +477,10 @@ "@anush008/tokenizers-win32-x64-msvc": ["@anush008/tokenizers-win32-x64-msvc@0.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/5kP0G96+Cr6947F0ZetXnmL31YCaN15dbNbh2NHg7TXXRwfqk95+JtPP5Q7v4jbR2xxAmuseBqB4H/V7zKWuw=="], + "@ark/attest": ["@ark/attest@0.56.3", "", { "dependencies": { "@ark/fs": "0.56.2", "@ark/schema": "0.56.2", "@ark/util": "0.56.2", "@prettier/sync": "0.6.1", "@typescript/analyze-trace": "0.10.1", "@typescript/vfs": "1.6.1", "arktype": "2.2.3", "prettier": "3.6.2" }, "peerDependencies": { "typescript": "*" }, "bin": { "attest": "out/cli/cli.js" } }, "sha512-34YxcziljIzh1mHGaUoBcAGxqEkvFWObai9BhzQF3zkedUUT1ERqOAiqgHf+2wG9D03f7ErHbb/HYKUjkiXxjg=="], + + "@ark/fs": ["@ark/fs@0.56.2", "", {}, "sha512-mnr9H4P5stD8AHacOUN5nyIiDe8wfCWJQ0l/JZtDGjrnbgIFb2NHRcKf2ym3ielRFgwsnTIZg4P1J6CqfbjM8g=="], + "@ark/schema": ["@ark/schema@0.56.2", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg=="], "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], @@ -472,7 +491,7 @@ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], @@ -492,15 +511,15 @@ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], "@biomejs/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="], @@ -788,6 +807,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"], @@ -850,6 +871,8 @@ "@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + "@prettier/sync": ["@prettier/sync@0.6.1", "", { "dependencies": { "make-synchronized": "^0.8.0" }, "peerDependencies": { "prettier": "*" } }, "sha512-yF9G8vK/LYUTF3Cijd7VC9La3b20F20/J/fgoR4H0B8JGOWnZVZX6+I6+vODPosjmMcpdlUV+gUqJQZp3kLOcw=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -902,6 +925,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=="], @@ -968,6 +993,8 @@ "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], + "@typescript/analyze-trace": ["@typescript/analyze-trace@0.10.1", "", { "dependencies": { "chalk": "^4.1.2", "exit": "^0.1.2", "jsonparse": "^1.3.1", "jsonstream-next": "^3.0.0", "p-limit": "^3.1.0", "split2": "^3.2.2", "treeify": "^1.1.0", "yargs": "^16.2.0" }, "bin": { "analyze-trace": "bin/analyze-trace", "print-trace-types": "bin/print-trace-types", "simplify-trace-types": "bin/simplify-trace-types" } }, "sha512-RnlSOPh14QbopGCApgkSx5UBgGda5MX1cHqp2fsqfiDyCwGL/m1jaeB9fzu7didVS81LQqGZZuxFBcg8YU8EVw=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="], @@ -1024,7 +1051,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], + "@typescript/vfs": ["@typescript/vfs@1.6.1", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], @@ -1034,7 +1061,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], @@ -1054,7 +1081,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], @@ -1098,9 +1125,9 @@ "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], - "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], @@ -1172,7 +1199,7 @@ "emnapi": ["emnapi@1.11.3", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-+/ZS90YK/rYfVOHtGLHkGffVsnmD/MAKaBHio+Y4XAtg75RLr4cveV/w0jTkUdLM1CcAlaRgG76mpIemWAlk0A=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], @@ -1192,6 +1219,8 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1222,7 +1251,7 @@ "gearhash-jit": ["gearhash-jit@1.0.2", "", {}, "sha512-UhzJL4KXSdqAKepy/tZwmi2Rcy0YMmtiC4DQS4SURCuIWdh8ECZtnXK2ePRMLigfB61hRKdLK/Vgg2bSw73izQ=="], - "generative-bayesian-network": ["generative-bayesian-network@2.1.86", "", { "dependencies": { "adm-zip": "^0.6.0", "tslib": "^2.4.0" } }, "sha512-4+cZAUH6khje/EUKRlTlO+HpjA7aqvDlVBnBSJKZqC6qiJ3EitNZ97//xYrBbawZCHe1S1PGLza6knQTFpWCgw=="], + "generative-bayesian-network": ["generative-bayesian-network@2.1.87", "", { "dependencies": { "adm-zip": "^0.6.0", "tslib": "^2.4.0" } }, "sha512-Bd14uZSkGGef6gWsJp170xnTMxeMENcnkJega4UjFrgF0KHSG8IOYA5hgFVOIgWlfyYUy9B2qiLGvXsKR/TlYA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -1244,9 +1273,11 @@ "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - "header-generator": ["header-generator@2.1.86", "", { "dependencies": { "browserslist": "^4.21.1", "generative-bayesian-network": "2.1.86", "ow": "^0.28.1", "tslib": "^2.4.0" } }, "sha512-Nj+glwXBEU3PvvAT1WiC3FBhX1ReZZY+ADXIo2iTRqMzamLW0olJk7q/EJ6N3qJBqQWZJDJonAJRtAlzEFynLQ=="], + "header-generator": ["header-generator@2.1.87", "", { "dependencies": { "browserslist": "^4.21.1", "generative-bayesian-network": "2.1.87", "ow": "^0.28.1", "tslib": "^2.4.0" } }, "sha512-J3MG508JWaH3UJtHlPM3xaOQ2Iqys8BiW07t6XehzP+HuvVX4MP4C2YztJKasDmB8R3yPwpBfyroOiEi4Ymn6Q=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], @@ -1278,7 +1309,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1288,6 +1319,10 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "jsonstream-next": ["jsonstream-next@3.0.0", "", { "dependencies": { "jsonparse": "^1.2.0", "through2": "^4.0.2" }, "bin": { "jsonstream-next": "bin.js" } }, "sha512-aAi6oPhdt7BKyQn1SrIIGZBt0ukKuOUE1qV6kJ3GgioSOYzsRc8z9Hfr1BVmacA/jLe9nARfmgMGgn68BqIAgg=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], @@ -1336,6 +1371,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "make-synchronized": ["make-synchronized@0.8.0", "", {}, "sha512-DZu4lwc0ffoFz581BSQa/BJl+1ZqIkoRQ+VejMlH0VrP4E86StAODnZujZ4sepumQj8rcP7wUnUBGM8Gu+zKUA=="], + "mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="], "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], @@ -1398,6 +1435,8 @@ "ow": ["ow@0.28.2", "", { "dependencies": { "@sindresorhus/is": "^4.2.0", "callsites": "^3.1.0", "dot-prop": "^6.0.1", "lodash.isequal": "^4.5.0", "vali-date": "^1.0.0" } }, "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -1438,6 +1477,8 @@ "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], "robomp-web": ["robomp-web@workspace:python/robomp/web"], @@ -1492,6 +1533,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], @@ -1506,6 +1549,8 @@ "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -1516,10 +1561,14 @@ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "treeify": ["treeify@1.1.0", "", {}, "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A=="], + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], "ts-morph": ["ts-morph@28.0.0", "", { "dependencies": { "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g=="], @@ -1588,14 +1637,20 @@ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@ark/attest/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@bufbuild/protoplugin/@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], + "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], "@huggingface/transformers/onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], @@ -1660,6 +1715,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript/analyze-trace/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@typescript/analyze-trace/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1668,6 +1727,10 @@ "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "color-string/color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], "dom-serializer/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -1702,6 +1765,8 @@ "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1716,10 +1781,18 @@ "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], - "cli-progress/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@typescript/analyze-trace/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "@typescript/analyze-trace/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@typescript/analyze-trace/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], "cli-progress/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "color/color-convert/color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "fastembed/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "fastembed/onnxruntime-node/onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="], @@ -1752,10 +1825,18 @@ "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "@huggingface/transformers/onnxruntime-node/global-agent/matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "@huggingface/transformers/onnxruntime-node/global-agent/serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "@typescript/analyze-trace/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@typescript/analyze-trace/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@typescript/analyze-trace/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cli-progress/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "fastembed/onnxruntime-node/global-agent/matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -1774,6 +1855,10 @@ "@huggingface/transformers/onnxruntime-node/global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "@typescript/analyze-trace/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@typescript/analyze-trace/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "fastembed/onnxruntime-node/global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], } } diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index a61ceff6c..0606eaa48 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -255,7 +255,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV17_2_6")] +#[napi(js_name = "__piNativesV17_2_8")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index f752466a5..5d0046b50 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -1972,7 +1972,7 @@ mod proc_snapshot { stat: Stat, args: Vec, uid: Option<(u32, u32)>, - gid: Option, + gid: Option<(u32, u32)>, } #[derive(Clone)] @@ -1983,8 +1983,13 @@ mod proc_snapshot { pgrp: i32, session: i32, tty: i64, + tpgid: i32, + flags: u64, + minflt: u64, + majflt: u64, utime: u64, stime: u64, + priority: i32, nice: i32, threads: u32, start_time: u64, @@ -2033,7 +2038,7 @@ mod proc_snapshot { }) .unwrap_or_default(); let uid = status_ids(pid, "Uid:").map(|ids| (ids.0, ids.1)); - let gid = status_ids(pid, "Gid:").map(|ids| ids.0); + let gid = status_ids(pid, "Gid:"); (read_stat(pid)?.start_time == stat.start_time).then_some(Self { pid, stat, @@ -2071,14 +2076,44 @@ mod proc_snapshot { self.uid.map(|ids| ids.1) } - pub const fn real_group_id(&self) -> Option { - self.gid + pub fn real_group_id(&self) -> Option { + self.gid.map(|ids| ids.0) + } + + pub fn effective_group_id(&self) -> Option { + self.gid.map(|ids| ids.1) } pub fn terminal_id(&self) -> Option { (self.stat.tty != 0).then_some(self.stat.tty as u32 as u64) } + pub fn terminal_group_id(&self) -> Option { + (self.stat.tpgid > 0).then_some(self.stat.tpgid) + } + + pub const fn priority(&self) -> Option { + Some(self.stat.priority) + } + + pub const fn flags(&self) -> Option { + Some(self.stat.flags) + } + + pub const fn minor_faults(&self) -> Option { + Some(self.stat.minflt) + } + + pub const fn major_faults(&self) -> Option { + Some(self.stat.majflt) + } + + pub fn wchan(&self) -> Option { + let value = fs::read_to_string(format!("/proc/{}/wchan", self.pid)).ok()?; + let value = value.trim(); + (!value.is_empty() && value != "0" && value != "-").then(|| value.to_string()) + } + pub const fn state(&self) -> char { self.stat.state } @@ -2191,8 +2226,13 @@ mod proc_snapshot { pgrp: fields.get(2)?.parse().ok()?, session: fields.get(3)?.parse().ok()?, tty: fields.get(4)?.parse().ok()?, + tpgid: fields.get(5)?.parse().ok()?, + flags: fields.get(6)?.parse().ok()?, + minflt: fields.get(7)?.parse().ok()?, + majflt: fields.get(9)?.parse().ok()?, utime: fields.get(11)?.parse().ok()?, stime: fields.get(12)?.parse().ok()?, + priority: fields.get(15)?.parse().ok()?, nice: fields.get(16)?.parse().ok()?, threads: fields.get(17)?.parse().ok()?, start_time: fields.get(19)?.parse().ok()?, @@ -2326,6 +2366,37 @@ mod proc_snapshot { (!matches!(self.info.e_tdev, 0 | u32::MAX)).then_some(self.info.e_tdev as u64) } + pub fn terminal_group_id(&self) -> Option { + i32::try_from(self.info.e_tpgid) + .ok() + .filter(|tpgid| *tpgid > 0) + } + + pub const fn effective_group_id(&self) -> Option { + Some(self.info.pbi_gid) + } + + pub fn priority(&self) -> Option { + Some(self.task.as_ref()?.pti_priority) + } + + pub const fn flags(&self) -> Option { + Some(self.info.pbi_flags as u64) + } + + pub fn minor_faults(&self) -> Option { + u64::try_from(self.task.as_ref()?.pti_faults).ok() + } + + pub fn major_faults(&self) -> Option { + u64::try_from(self.task.as_ref()?.pti_pageins).ok() + } + + #[allow(clippy::unused_self, reason = "matches the cross-platform ProcInfo contract")] + pub const fn wchan(&self) -> Option { + None + } + pub const fn state(&self) -> char { match self.info.pbi_status { 1 => 'I', @@ -2703,6 +2774,34 @@ mod proc_snapshot { None } + pub fn terminal_group_id(&self) -> Option { + None + } + + pub fn effective_group_id(&self) -> Option { + None + } + + pub fn priority(&self) -> Option { + None + } + + pub fn flags(&self) -> Option { + None + } + + pub fn minor_faults(&self) -> Option { + None + } + + pub fn major_faults(&self) -> Option { + None + } + + pub fn wchan(&self) -> Option { + None + } + pub fn state(&self) -> char { if self.status() == ProcessStatus::Running { 'R' @@ -4413,6 +4512,21 @@ enum PsField { Threads, Command, Args, + Tpgid, + StateChar, + Priority, + Flags, + Ruser, + Ruid, + Rgroup, + Rgid, + Egroup, + Egid, + Wchan, + MinorFaults, + MajorFaults, + CpuSeconds, + Size, } impl PsField { @@ -4440,6 +4554,21 @@ impl PsField { Self::Threads => "NLWP", Self::Command => "COMMAND", Self::Args => "COMMAND", + Self::Tpgid => "TPGID", + Self::StateChar => "S", + Self::Priority => "PRI", + Self::Flags => "F", + Self::Ruser => "RUSER", + Self::Ruid => "RUID", + Self::Rgroup => "RGROUP", + Self::Rgid => "RGID", + Self::Egroup => "GROUP", + Self::Egid => "GID", + Self::Wchan => "WCHAN", + Self::MinorFaults => "MINFL", + Self::MajorFaults => "MAJFL", + Self::CpuSeconds => "TIME", + Self::Size => "SZ", } } @@ -4459,6 +4588,16 @@ impl PsField { | Self::ResidentSize | Self::Nice | Self::Threads + | Self::Tpgid + | Self::Priority + | Self::Flags + | Self::Ruid + | Self::Rgid + | Self::Egid + | Self::MinorFaults + | Self::MajorFaults + | Self::CpuSeconds + | Self::Size ) } } @@ -4507,7 +4646,11 @@ struct PsProcessRow { ppid: Option, pgid: Option, sid: Option, + tpgid: Option, user: Option, + ruid: Option, + rgid: Option, + egid: Option, terminal: Option, state: char, start_time: u64, @@ -4518,6 +4661,11 @@ struct PsProcessRow { resident_size: Option, threads: Option, nice: Option, + priority: Option, + flags: Option, + minor_faults: Option, + major_faults: Option, + wchan: Option, command: String, args: String, } @@ -4537,9 +4685,13 @@ impl PsProcessRow { ppid: process.ppid(), pgid: process.group_id(), sid: process.session_id(), + tpgid: process.terminal_group_id(), user: process .effective_user_id() .or_else(|| process.real_user_id()), + ruid: process.real_user_id(), + rgid: process.real_group_id(), + egid: process.effective_group_id(), terminal: process.terminal_id(), state: process.state(), start_time: process.start_time(), @@ -4550,6 +4702,11 @@ impl PsProcessRow { resident_size: process.resident_bytes(), threads: process.thread_count(), nice: process.nice(), + priority: process.priority(), + flags: process.flags(), + minor_faults: process.minor_faults(), + major_faults: process.major_faults(), + wchan: process.wchan(), command, args, } @@ -4854,25 +5011,40 @@ fn parse_ps_format( .and_then(|(name, width)| width.parse::().ok().map(|width| (name, width))) .unwrap_or((field_spec, 0)); let field = match name.to_ascii_lowercase().as_str() { - "user" | "uname" => PsField::User, + "user" | "uname" | "euser" => PsField::User, "uid" | "euid" => PsField::Uid, - "pid" | "lwp" => PsField::Pid, + "pid" | "lwp" | "tid" | "spid" | "tgid" => PsField::Pid, "ppid" => PsField::Ppid, "pgid" | "pgrp" => PsField::Pgid, "sid" | "sess" => PsField::Sid, + "tpgid" => PsField::Tpgid, "tty" | "tt" | "tname" => PsField::Tty, - "stat" | "state" | "s" => PsField::State, + "stat" | "state" => PsField::State, + "s" => PsField::StateChar, "start" | "stime" | "bsdstart" => PsField::Start, "lstart" | "start_time" => PsField::LongStart, "etime" | "elapsed" => PsField::Elapsed, "etimes" => PsField::ElapsedSeconds, - "time" | "cputime" => PsField::CpuTime, + "time" | "cputime" | "bsdtime" => PsField::CpuTime, + "times" | "cputimes" => PsField::CpuSeconds, "pcpu" | "%cpu" => PsField::CpuPercent, "c" => PsField::CpuInteger, "pmem" | "%mem" => PsField::MemPercent, "vsz" | "vsize" => PsField::VirtualSize, - "rss" | "rssize" => PsField::ResidentSize, + "rss" | "rssize" | "rsz" => PsField::ResidentSize, + "sz" => PsField::Size, "ni" | "nice" => PsField::Nice, + "pri" | "opri" | "priority" => PsField::Priority, + "f" | "flag" | "flags" => PsField::Flags, + "ruser" | "logname" => PsField::Ruser, + "ruid" => PsField::Ruid, + "rgroup" => PsField::Rgroup, + "rgid" => PsField::Rgid, + "group" | "egroup" => PsField::Egroup, + "gid" | "egid" => PsField::Egid, + "wchan" | "mwchan" => PsField::Wchan, + "min_flt" | "minflt" => PsField::MinorFaults, + "maj_flt" | "majflt" => PsField::MajorFaults, "nlwp" | "thcount" => PsField::Threads, "comm" | "ucomm" | "fname" => PsField::Command, "args" | "command" | "cmd" => PsField::Args, @@ -5003,7 +5175,7 @@ fn ps_columns(options: &PsOptions) -> Vec { ] } else if options.long_format { vec![ - (PsField::State, "S"), + (PsField::StateChar, "S"), (PsField::Uid, "UID"), (PsField::Pid, "PID"), (PsField::Ppid, "PPID"), @@ -5023,6 +5195,7 @@ fn ps_columns(options: &PsOptions) -> Vec { (PsField::Ppid, "PPID"), (PsField::Pgid, "PGID"), (PsField::Sid, "SID"), + (PsField::Tpgid, "TPGID"), (PsField::State, "STAT"), (PsField::Tty, "TTY"), (PsField::CpuTime, "TIME"), @@ -5114,13 +5287,23 @@ fn render_ps_table(rows: &[PsProcessRow], columns: &[PsColumn], no_headers: bool HashMap::new() }; let mut user_names = HashMap::new(); - if has_field(PsField::User) { - for uid in rows.iter().filter_map(|row| row.user) { + if has_field(PsField::User) || has_field(PsField::Ruser) { + let uids = rows.iter().flat_map(|row| [row.user, row.ruid]).flatten(); + for uid in uids { user_names .entry(uid) .or_insert_with(|| ps_user_name(uid).unwrap_or_else(|| uid.to_string())); } } + let mut group_names = HashMap::new(); + if has_field(PsField::Rgroup) || has_field(PsField::Egroup) { + let gids = rows.iter().flat_map(|row| [row.rgid, row.egid]).flatten(); + for gid in gids { + group_names + .entry(gid) + .or_insert_with(|| ps_group_name(gid).unwrap_or_else(|| gid.to_string())); + } + } let values: Vec> = rows .iter() .map(|row| { @@ -5134,6 +5317,7 @@ fn render_ps_table(rows: &[PsProcessRow], columns: &[PsColumn], no_headers: bool timezone.as_ref(), &terminal_names, &user_names, + &group_names, ) }) .collect() @@ -5193,6 +5377,7 @@ fn render_ps_value( timezone: Option<&TimeZone>, terminal_names: &HashMap, user_names: &HashMap, + group_names: &HashMap, ) -> String { match field { PsField::User => row @@ -5212,11 +5397,15 @@ fn render_ps_value( PsField::Sid => row .sid .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Tpgid => row + .tpgid + .map_or_else(|| "-1".to_string(), |value| value.to_string()), PsField::Tty => row .terminal .and_then(|terminal| terminal_names.get(&terminal).cloned()) .unwrap_or_else(|| "?".to_string()), PsField::State => format_ps_state(row), + PsField::StateChar => row.state.to_string(), PsField::Start => timezone.map_or_else( || "?".to_string(), |timezone| format_ps_start(row.started_at, row.age, timezone, false), @@ -5253,6 +5442,47 @@ fn render_ps_value( PsField::Threads => row .threads .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Priority => row + .priority + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Flags => row + .flags + .map_or_else(|| "?".to_string(), |value| format!("{value:x}")), + PsField::Ruser => row + .ruid + .and_then(|uid| user_names.get(&uid).cloned()) + .unwrap_or_else(|| "?".to_string()), + PsField::Ruid => row + .ruid + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Rgroup => row + .rgid + .and_then(|gid| group_names.get(&gid).cloned()) + .unwrap_or_else(|| "?".to_string()), + PsField::Rgid => row + .rgid + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Egroup => row + .egid + .and_then(|gid| group_names.get(&gid).cloned()) + .unwrap_or_else(|| "?".to_string()), + PsField::Egid => row + .egid + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Wchan => row.wchan.clone().unwrap_or_else(|| "-".to_string()), + PsField::MinorFaults => row + .minor_faults + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::MajorFaults => row + .major_faults + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::CpuSeconds => row + .cpu_time + .map_or_else(|| "?".to_string(), |time| time.as_secs().to_string()), + PsField::Size => row + .virtual_size + .zip(ps_page_size()) + .map_or_else(|| "?".to_string(), |(bytes, page)| bytes.div_ceil(page).to_string()), PsField::Command => row.command.clone(), PsField::Args => row.args.clone(), } @@ -5271,6 +5501,9 @@ fn format_ps_state(row: &PsProcessRow) -> String { if row.threads.is_some_and(|threads| threads > 1) { state.push('l'); } + if row.terminal.is_some() && row.tpgid.is_some() && row.tpgid == row.pgid { + state.push('+'); + } state } @@ -5388,6 +5621,50 @@ fn ps_user_name(_uid: u32) -> Option { None } +#[cfg(unix)] +fn ps_group_name(gid: u32) -> Option { + use std::ffi::CStr; + let mut record = std::mem::MaybeUninit::::zeroed(); + let mut result = std::ptr::null_mut(); + let mut buffer = vec![0_u8; 16 * 1024]; + // SAFETY: all pointers refer to live storage for this call; a non-null + // result guarantees `record` and its gr_name pointer were initialized. + let status = unsafe { + libc::getgrgid_r( + gid, + record.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &raw mut result, + ) + }; + if status != 0 || result.is_null() { + return None; + } + // SAFETY: getgrgid_r succeeded and the backing buffer remains alive. + let name = unsafe { CStr::from_ptr(record.assume_init().gr_name) }; + Some(name.to_string_lossy().into_owned()) +} + +#[cfg(not(unix))] +fn ps_group_name(_gid: u32) -> Option { + None +} + +/// System memory page size in bytes, used for the SZ (pages) column. +#[cfg(unix)] +fn ps_page_size() -> Option { + // SAFETY: sysconf reads a process-global constant. + u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }) + .ok() + .filter(|value| *value > 0) +} + +#[cfg(not(unix))] +fn ps_page_size() -> Option { + None +} + #[cfg(unix)] fn ps_terminal_names(rows: &[PsProcessRow]) -> HashMap { use std::os::unix::fs::MetadataExt; @@ -6283,6 +6560,60 @@ mod tests { assert!(strtime::parse("%a %b %e %H:%M:%S %Y", output.trim()).is_ok(), "{output:?}"); } + #[tokio::test(flavor = "multi_thread")] + async fn ps_builtin_supports_extended_format_specifiers() { + let pid = std::process::id(); + let (result, output) = execute_captured(format!( + "ps -p {pid} -o \ + pid=,tpgid=,ruid=,rgid=,egid=,pri=,f=,min_flt=,maj_flt=,times=,sz=,s=,ruser=,rgroup=,\ + tgid=" + )) + .await; + assert_eq!(result.exit_code, Some(0), "{output:?}"); + let fields: Vec<&str> = output.split_whitespace().collect(); + assert_eq!(fields.len(), 15, "{output:?}"); + assert_eq!(fields[0], pid.to_string()); + assert_eq!(fields[14], pid.to_string(), "tgid aliases pid"); + #[cfg(unix)] + { + // tpgid is an integer; -1 without a controlling terminal. + assert!(fields[1].parse::().is_ok(), "tpgid: {:?}", fields[1]); + for (index, name) in [(2, "ruid"), (3, "rgid"), (4, "egid")] { + assert!(fields[index].parse::().is_ok(), "{name}: {:?}", fields[index]); + } + assert!(fields[5].parse::().is_ok(), "pri: {:?}", fields[5]); + assert!(u64::from_str_radix(fields[6], 16).is_ok(), "flags: {:?}", fields[6]); + assert!(fields[7].parse::().is_ok(), "min_flt: {:?}", fields[7]); + assert!(fields[8].parse::().is_ok(), "maj_flt: {:?}", fields[8]); + assert!(fields[9].parse::().is_ok(), "times: {:?}", fields[9]); + assert!(fields[10].parse::().is_ok(), "sz: {:?}", fields[10]); + assert_eq!(fields[11].chars().count(), 1, "state: {:?}", fields[11]); + assert_ne!(fields[12], "?", "ruser should resolve to a name"); + assert_ne!(fields[13], "?", "rgroup should resolve to a name"); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn ps_builtin_accepts_tpgid_alongside_job_control_columns() { + // Exact form from the field report that failed with + // "unknown output format specifier 'tpgid'". + let pid = std::process::id(); + let (result, output) = + execute_captured(format!("ps -o pid,ppid,pgid,tpgid,sess,stat,tty,command -p {pid}")) + .await; + assert_eq!(result.exit_code, Some(0), "{output:?}"); + let header = output.lines().next().unwrap_or_default(); + for label in ["PID", "PPID", "PGID", "TPGID", "SID", "STAT", "TTY", "COMMAND"] { + assert!(header.contains(label), "missing {label} in {header:?}"); + } + assert!(output.lines().skip(1).any(|line| { + line + .split_whitespace() + .next() + .is_some_and(|value| value == pid.to_string()) + })); + } + #[tokio::test(flavor = "multi_thread")] async fn top_builtin_emits_one_finite_snapshot() { #[cfg(target_os = "macos")] @@ -6309,6 +6640,29 @@ mod tests { assert!(execution.1.contains("top - snapshot 1"), "{:?}", execution.1); } + /// Regression: builtin tail upstream of an early-exiting consumer printed + /// "tail: Broken pipe" and failed (`tail -c N big.jsonl | jq …` with jq + /// aborting on a parse error). Real tail dies silently from SIGPIPE; the + /// builtin must exit 141 with no diagnostic. `pipefail` exposes tail's own + /// status, so rc=141 proves the broken pipe actually fired (head closed + /// the pipe early) and was mapped to the silent SIGPIPE exit, not 1. + #[tokio::test(flavor = "multi_thread")] + async fn tail_builtin_is_silent_when_downstream_closes_pipe() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("big.txt"); + // ~589 KiB: forces the seekable bounded_tail path, and the 400 KB tail + // overflows the OS pipe buffer so tail is still writing when head exits. + let command = format!( + "seq 1 100000 > '{file}'; set -o pipefail; tail -c 400000 '{file}' | head -c 10 > \ + /dev/null; echo rc=$?", + file = file.display() + ); + let (result, output) = execute_captured(command).await; + assert_eq!(result.exit_code, Some(0)); + assert!(output.contains("rc=141"), "{output:?}"); + assert!(!output.contains("Broken pipe"), "{output:?}"); + } + /// The kill builtin accepts a numeric signal and applies it to every process /// operand. #[cfg(unix)] diff --git a/crates/vendor/uu-tail/src/chunks.rs b/crates/vendor/uu-tail/src/chunks.rs index 6df075f08..0f3bca3d8 100644 --- a/crates/vendor/uu-tail/src/chunks.rs +++ b/crates/vendor/uu-tail/src/chunks.rs @@ -15,11 +15,9 @@ use std::{ collections::VecDeque, fs::File, - io::{BufRead, Read, Seek, SeekFrom, Write}, + io::{self, BufRead, Read, Seek, SeekFrom, Write}, }; -use uucore::error::UResult; - /// When reading files in reverse in `bounded_tail`, this is the size of each /// block read at a time. pub const BLOCK_SIZE: u64 = 1 << 16; @@ -209,10 +207,10 @@ impl BytesChunk { /// Fills `self.buffer` with maximal [`BUFFER_SIZE`] number of bytes, /// draining the reader by that number of bytes. If EOF is reached (so 0 - /// bytes are read), it returns [`UResult`]; otherwise, it returns - /// [`UResult`], where bytes is the number of bytes read from + /// bytes are read), it returns `Ok(None)`; otherwise, it returns + /// `Ok(Some(bytes))`, where bytes is the number of bytes read from /// the source. - pub fn fill(&mut self, filehandle: &mut impl BufRead) -> UResult> { + pub fn fill(&mut self, filehandle: &mut impl BufRead) -> io::Result> { let num_bytes = filehandle.read(&mut self.buffer)?; self.bytes = num_bytes; if num_bytes == 0 { @@ -286,7 +284,7 @@ impl BytesChunkBuffer { /// let mut chunks = BytesChunkBuffer::new(num_print); /// chunks.fill(&mut reader).unwrap(); /// ``` - pub fn fill(&mut self, reader: &mut impl BufRead) -> UResult<()> { + pub fn fill(&mut self, reader: &mut impl BufRead) -> io::Result<()> { let mut chunk = Box::new(BytesChunk::new()); // fill chunks with all bytes from reader and reuse already instantiated chunks @@ -323,7 +321,7 @@ impl BytesChunkBuffer { Ok(()) } - pub fn print(&self, writer: &mut impl Write) -> UResult<()> { + pub fn print(&self, writer: &mut impl Write) -> io::Result<()> { for chunk in &self.chunks { writer.write_all(chunk.get_buffer())?; } @@ -459,7 +457,7 @@ impl LinesChunk { /// the [`BytesChunk::fill`] function besides that this function also counts /// and stores the number of lines encountered while reading from /// the `filehandle`. - pub fn fill(&mut self, filehandle: &mut impl BufRead) -> UResult> { + pub fn fill(&mut self, filehandle: &mut impl BufRead) -> io::Result> { match self.chunk.fill(filehandle)? { None => { self.lines = 0; @@ -517,7 +515,7 @@ impl LinesChunk { /// /// * `writer`: must implement [`Write`] /// * `offset`: An offset in number of lines. - pub fn write_lines(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { + pub fn write_lines(&self, writer: &mut impl Write, offset: usize) -> io::Result<()> { self.write_bytes(writer, self.calculate_bytes_offset_from(offset)) } @@ -528,7 +526,7 @@ impl LinesChunk { /// /// * `writer`: must implement [`Write`] /// * `offset`: An offset in number of bytes. - pub fn write_bytes(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { + pub fn write_bytes(&self, writer: &mut impl Write, offset: usize) -> io::Result<()> { writer.write_all(self.get_buffer_with(offset))?; Ok(()) } @@ -565,7 +563,7 @@ impl LinesChunkBuffer { /// chunks. If there are no chunks, for example because the piped stdin /// contained no lines, or `num_print = 0` then `iterator.next` will return /// None. - pub fn fill(&mut self, reader: &mut impl BufRead) -> UResult<()> { + pub fn fill(&mut self, reader: &mut impl BufRead) -> io::Result<()> { let mut chunk = Box::new(LinesChunk::new(self.delimiter)); while chunk.fill(reader)?.is_some() { @@ -620,7 +618,7 @@ impl LinesChunkBuffer { Ok(()) } - pub fn write(&self, mut writer: impl Write) -> UResult<()> { + pub fn write(&self, mut writer: impl Write) -> io::Result<()> { for chunk in &self.chunks { chunk.write_bytes(&mut writer, 0)?; } diff --git a/crates/vendor/uu-tail/src/follow/files.rs b/crates/vendor/uu-tail/src/follow/files.rs index 8c011aaa9..6c5f3636c 100644 --- a/crates/vendor/uu-tail/src/follow/files.rs +++ b/crates/vendor/uu-tail/src/follow/files.rs @@ -154,9 +154,11 @@ impl FileHandling { self.header_printer.print(display_name.as_str()); } + // pi-uutils: a closed downstream reader must end the follow loop + // silently (SIGPIPE semantics), not as a printed tail error. let mut writer = BufWriter::new(pi_uutils_ctx::stdout().lock()); - chunks.print(&mut writer)?; - writer.flush()?; + chunks.print(&mut writer).map_err(crate::map_output_error)?; + writer.flush().map_err(crate::map_output_error)?; self.last.replace(path.to_owned()); self.update_metadata(path, None); diff --git a/crates/vendor/uu-tail/src/tail.rs b/crates/vendor/uu-tail/src/tail.rs index 4b5c596c4..9dcda9394 100644 --- a/crates/vendor/uu-tail/src/tail.rs +++ b/crates/vendor/uu-tail/src/tail.rs @@ -123,6 +123,43 @@ fn rewrite_bsd_invocation(argv: &[OsString]) -> Option, Str Some(Ok(tac_argv)) } +/// Exit status of a process killed by SIGPIPE (128 + 13). +const SIGPIPE_EXIT_CODE: i32 = 141; + +/// pi-uutils: marker for a closed stdout pipe. Real tail is killed silently by +/// SIGPIPE when the downstream reader exits early (`tail file | head`); the +/// in-process builtin cannot die by signal, so output errors map to this +/// sentinel and [`run`] turns it into a bare exit 141 with no diagnostic, +/// mirroring the fd builtin's convention. +#[derive(Debug)] +struct BrokenPipeExit; + +impl std::fmt::Display for BrokenPipeExit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Broken pipe") + } +} + +impl std::error::Error for BrokenPipeExit {} + +impl UError for BrokenPipeExit { + fn code(&self) -> i32 { + SIGPIPE_EXIT_CODE + } +} + +/// Maps a stdout write error into the tail exit contract: `BrokenPipe` +/// becomes the silent [`BrokenPipeExit`] sentinel, everything else the +/// standard uucore io wrapper. Used by every path that copies data to the +/// context stdout, including follow mode. +pub(crate) fn map_output_error(err: io::Error) -> Box { + if err.kind() == ErrorKind::BrokenPipe { + Box::new(BrokenPipeExit) + } else { + err.into() + } +} + /// In-process builtin entry point. Unlike upstream's `#[uucore::main] uumain`, /// this renders clap help/usage/version to the context streams and never calls /// `std::process::exit`, so it is safe inside the long-lived host shell @@ -160,6 +197,11 @@ pub fn run(args: Vec) -> i32 { Ok(()) => pi_uutils_ctx::exit_code(), Err(err) => { let code = err.code(); + if code == SIGPIPE_EXIT_CODE { + // Downstream closed the pipe; exit silently like SIGPIPE would + // kill a real tail (see `BrokenPipeExit`). + return SIGPIPE_EXIT_CODE; + } let _ = writeln!(pi_uutils_ctx::stderr(), "tail: {err}"); if code == 0 { 1 } else { code } }, @@ -299,11 +341,11 @@ fn tail_file( && file.is_seekable(if input.is_stdin() { offset } else { 0 }) && (!st.is_file() || st.len() > blksize_limit) { - bounded_tail(&mut file, settings)?; + bounded_tail(&mut file, settings).map_err(map_output_error)?; reader = BufReader::new(file); } else { reader = BufReader::new(file); - unbounded_tail(&mut reader, settings)?; + unbounded_tail(&mut reader, settings).map_err(map_output_error)?; } if input.is_tailable() { observer.add_path( @@ -391,7 +433,7 @@ fn tail_stdin( // streaming (pipe) path. header_printer.print_input(input); let mut reader = BufReader::new(pi_uutils_ctx::stdin()); - unbounded_tail(&mut reader, settings)?; + unbounded_tail(&mut reader, settings).map_err(map_output_error)?; Ok(()) } @@ -531,7 +573,7 @@ fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) { /// end of the file, and then read the file "backwards" in blocks of size /// `BLOCK_SIZE` until we find the location of the first line/byte. This ends up /// being a nice performance win for very large files. -fn bounded_tail(file: &mut File, settings: &Settings) -> UResult<()> { +fn bounded_tail(file: &mut File, settings: &Settings) -> io::Result<()> { debug_assert!(!settings.presume_input_pipe); let mut limit = None; @@ -568,7 +610,7 @@ fn bounded_tail(file: &mut File, settings: &Settings) -> UResult<()> { Ok(()) } -fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UResult<()> { +fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> io::Result<()> { let mut writer = BufWriter::new(pi_uutils_ctx::stdout().lock()); match &settings.mode { FilterMode::Lines(Signum::Negative(count), sep) => { @@ -636,8 +678,8 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR // pi-uutils: upstream emulates Unix SIGPIPE on Windows by calling // `std::process::exit(13)` on a broken-pipe flush. That would kill the // long-lived host shell process. An in-process builtin must never - // `process::exit`; let the broken pipe surface as a normal `io::Error` and - // propagate to the caller, matching every other pi-uutils builtin. + // `process::exit`; the broken pipe surfaces as a normal `io::Error`, which + // callers map to the silent SIGPIPE exit via `map_output_error`. writer.flush()?; Ok(()) } @@ -834,12 +876,13 @@ mod tests { file.flush().expect("flush"); drop(file); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); let io = pi_uutils_ctx::ScopeIo { stdin: Box::new(io::empty()), stdin_fd: None, stdin_is_search_input: false, stdout: Box::new(BrokenPipeWriter), - stderr: Box::new(io::sink()), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), cwd: std::env::temp_dir(), env: HashMap::new(), cancel: Arc::new(AtomicBool::new(false)), @@ -849,7 +892,10 @@ mod tests { crate::run(vec![OsString::from("tail"), OsString::from(&path)]) }); - assert_ne!(code, 0, "broken pipe must surface as a non-zero exit, not a panic"); + // Real tail dies silently from SIGPIPE (exit 128+13); the in-process + // builtin must match: no "tail: Broken pipe" noise on stderr. + assert_eq!(code, 141, "broken pipe must map to the silent SIGPIPE exit"); + assert_eq!(String::from_utf8(stderr_buf.lock().clone()).unwrap(), ""); } #[test] @@ -877,12 +923,13 @@ mod tests { } let input = b"1\n2\n3\n4\n5\n".to_vec(); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); let io = pi_uutils_ctx::ScopeIo { stdin: Box::new(Cursor::new(input)), stdin_fd: None, stdin_is_search_input: false, stdout: Box::new(BrokenPipeWriter), - stderr: Box::new(io::sink()), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), cwd: std::env::temp_dir(), env: HashMap::new(), cancel: Arc::new(AtomicBool::new(false)), @@ -892,6 +939,9 @@ mod tests { crate::run(vec![OsString::from("tail"), OsString::from("-n"), OsString::from("3")]) }); - assert_ne!(code, 0, "broken pipe must surface as a non-zero exit, not process::exit"); + // Same silent-SIGPIPE contract as the bounded test: exit 141, no + // diagnostic — this is the `tail -c N file | jq | …` regression. + assert_eq!(code, 141, "broken pipe must map to the silent SIGPIPE exit"); + assert_eq!(String::from_utf8(stderr_buf.lock().clone()).unwrap(), ""); } } diff --git a/docs/advisor-watchdog.md b/docs/advisor-watchdog.md index 063897804..e75afefd2 100644 --- a/docs/advisor-watchdog.md +++ b/docs/advisor-watchdog.md @@ -178,6 +178,22 @@ Practical interpretation: Advisor failures do not permanently stall the primary. The host first attempts its credential/fallback recovery. Retriable failures are attempted up to three times before that backlog is dropped; three dropped-backlog cycles halt the runtime until an explicit reset, and a permanent request rejection can halt it after one cycle. A quota/usage-limit failure pauses the advisor with its batch retained until `/advisor` rebuilds it, configuration is reloaded, a new session starts, or the process restarts. Catch-up waiters are released as soon as an advisor is failing. +Unsafe Advisor output follows a separate quarantine path rather than that +three-attempt request-retry policy. Before tool dispatch, the runtime +quarantines a turn that requests non-bridge tools unavailable to the Advisor. +It also quarantines generated text/advice when an output-only destructive-shell +directive is detected, or when at least three output-only hazard classes match +among destructive shell, instruction override, denial instruction, and +account-deletion claim. A new instruction override paired with a destructive +command quoted in the input also qualifies. The entire Advisor turn, including +any advice in it, is discarded before dispatch. + +The first consecutive quarantine silently resets and re-primes the Advisor with +the latest pending context. A second consecutive quarantine emits one +deduplicated host warning, drops the affected batch, and resets the Advisor +context to break the loop. Any successful Advisor turn resets the quarantine +counter. + ## WATCHDOG.md `WATCHDOG.md` is advisor-only guidance. It is appended to the advisor system prompt; it is not injected into the primary agent's normal context and does not behave like `AGENTS.md`, `RULES.md`, or other context files. diff --git a/docs/ai-schema-normalize.md b/docs/ai-schema-normalize.md index b5709a54e..8ffc03cd5 100644 --- a/docs/ai-schema-normalize.md +++ b/docs/ai-schema-normalize.md @@ -51,17 +51,18 @@ Removed in the unified-flow refactor: ## Dispatcher mapping -| Provider transport(s) | Dispatcher | -| ------------------------------------------------------------------ | ----------------------------------------------------------------------- | -| `openai-completions`, `openai-responses`, `openai-codex-responses` | `adaptSchemaForStrict` (sanitize + enforce when strict mode is enabled) | -| OpenAI Responses family | `normalizeSchemaForOpenAIResponses` before strict-mode adaptation | -| Moonshot/Kimi native hosts using MFJS | `normalizeSchemaForMoonshot` | -| Grammar-flavored OpenAI-compatible hosts | `sanitizeSchemaForGrammar` | -| `ollama` | `sanitizeSchemaForOllama` | -| `google-generative-ai`, `google-vertex`, Gemini CLI | `normalizeSchemaForGoogle` | -| Cloud Code Assist Claude (Antigravity + GCA, `claude-*` model ids) | `normalizeSchemaForCCA` | -| MCP `inputSchema` ingestion | `normalizeSchemaForMCP` | -| `anthropic-messages` (native, not CCA) | per-provider whitelist in `anthropic.ts` | +| Provider transport(s) | Dispatcher | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `openai-completions` | `adaptSchemaForStrict` (sanitize + enforce when strict mode is enabled) | +| `openai-responses`, `openai-codex-responses` | `sanitizeSchemaForOpenAIResponses` before strict-mode adaptation | +| `azure-openai-responses` | `sanitizeSchemaForOpenAIResponses`; emits `strict: false` without adaptation | +| Moonshot/Kimi native hosts using MFJS | `normalizeSchemaForMoonshot` | +| Grammar-flavored OpenAI-compatible hosts | `sanitizeSchemaForGrammar` | +| `ollama` | `sanitizeSchemaForOllama` | +| `google-generative-ai`, `google-vertex`, Gemini CLI | `normalizeSchemaForGoogle` | +| Cloud Code Assist Claude (Antigravity + GCA, `claude-*` model ids) | `normalizeSchemaForCCA` | +| MCP `inputSchema` ingestion | `normalizeSchemaForMCP` | +| `anthropic-messages` (native, not CCA) | per-provider whitelist in `anthropic.ts` | Gemini CLI / Antigravity CCA MUST run the full `normalizeSchemaForCCA` pipeline (not just the first keyword-stripping pass) to keep parity with the diff --git a/docs/arktype-guide.md b/docs/arktype-guide.md deleted file mode 100644 index f300e59ae..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/auth-broker-gateway.md b/docs/auth-broker-gateway.md index fb6c8ea40..c340dddca 100644 --- a/docs/auth-broker-gateway.md +++ b/docs/auth-broker-gateway.md @@ -58,7 +58,7 @@ omp auth-broker status [--json] - `serve` opens the local SQLite store at `getAgentDbPath()` and binds an HTTP listener (default `127.0.0.1:8765`). On startup a token is ensured at `/auth-broker.token` (mode `0600`, `0700` parent dir). The background refresher refreshes any OAuth credential whose `expires - Date.now() < refreshSkewMs` (default 5 min) every `refreshIntervalMs` (default 60 s). - `token` prints the cached bearer or generates a new one. `--regenerate` rotates it. -- `login []` runs the per-provider OAuth flow locally — when no provider is supplied, it falls back to an interactive numbered picker. With `--via=user@host` it shells out `ssh -L :127.0.0.1: user@host omp auth-broker login ` so the OAuth callback hits the local browser but the credential is written on the broker host (`--via` requires ``). Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`. The OAuth dance is driven in-process via `AuthStorage.login()` — there is no longer a `pi-ai` bin to spawn. +- `login []` runs the per-provider OAuth flow locally — when no provider is supplied, it falls back to an interactive numbered picker. With `--via=user@host` it shells out `ssh -L :127.0.0.1: user@host omp auth-broker login ` so the OAuth callback hits the local browser but the credential is written on the broker host (`--via` requires ``). Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`, `devin:59653`, `gitlab-duo-agent:8080`, `zai-coding-plan:54548`. The OAuth dance is driven in-process via `AuthStorage.login()` — there is no longer a `pi-ai` bin to spawn. - `logout []` deletes every credential row for ``. With no argument it shows an interactive numbered picker of currently-stored providers. - `list` enumerates every registered OAuth provider id/name (the union of built-ins + `registerOAuthProvider` custom providers). `--json` emits a machine-readable array. - `import ` imports CLIProxyAPI-style JSON credentials into the local SQLite store. Maps `type` field → omp provider (`claude → anthropic`, `codex → openai-codex`, `gemini → google-gemini-cli`, `antigravity → google-antigravity`, `gemini-cli → google-gemini-cli`). @@ -86,6 +86,27 @@ omp auth-broker status [--json] Requests use `Authorization: Bearer `. The server compares against an in-memory token allow-list; the gateway’s implementation uses a timing-safe comparison. +#### Conditional snapshot long polling + +`GET /v1/snapshot?wait=` supports generation-based conditional polling. +Send the generation from a previous response in `If-None-Match`. The broker +accepts a non-negative integer generation as a bare tag, a quoted tag such as +`"42"`, or a weak quoted tag such as `W/"42"`. + +`wait` is parsed as a number, truncated to whole milliseconds, and clamped to +the range 0–30,000 ms; an absent or non-numeric value behaves as `0`. The +response state machine is: + +- If the tag is absent/invalid, differs from the current generation, or + `wait <= 0`, return the current redacted snapshot immediately with `200`. +- If the tag matches and `wait > 0`, wait for the generation to change. Return + the new snapshot with `200` when it changes, an empty `304` when the wait + expires unchanged, or an empty `499` when the caller disconnects. + +Every `200`, `304`, and `499` snapshot response carries the current generation +as a quoted `ETag`, plus `Cache-Control: no-store` and +`Vary: OMP-Auth-Broker-Capabilities`. + ### Codex block-scope compatibility Clients that understand per-meter Codex blocks send `OMP-Auth-Broker-Capabilities: codex-meter-block-scopes`. Snapshot responses then carry the canonical `chat` and `spark` scopes. Without that capability, the broker projects those rows to the legacy `shared` scope on the wire. diff --git a/docs/bash-tool-runtime.md b/docs/bash-tool-runtime.md index 0e6cdceec..a1f970e54 100644 --- a/docs/bash-tool-runtime.md +++ b/docs/bash-tool-runtime.md @@ -218,8 +218,12 @@ When `async.enabled` is true and the call passes `async: true`, `BashTool` start After execution: -1. A cancellation or missing exit status throws a tool error. -2. Timeout returns an error result with `details.timedOut = true` so the renderer can distinguish it from an ordinary failure. +1. A cancellation or missing exit status throws a tool error. The client-bridge + terminal route also throws `ToolError` for timeout before structured result + shaping. +2. Local non-PTY and interactive-PTY timeouts return an error result with + `details.timedOut = true` so the renderer can distinguish them from an + ordinary failure. 3. Empty output becomes `(no output)`. 4. A final inline byte cap protects routes that bypass `OutputSink`; it reuses the sink artifact when available or saves a `bash-original` artifact. 5. Truncation metadata is attached from the sink summary. @@ -271,7 +275,7 @@ This component is wired by `CommandController.handleBashCommand()` and fed from - Interceptor only blocks commands when suggested tool is currently available in context. - If artifact allocation fails, truncation still occurs but no `artifact://` back-reference is available. - Shell session cache has no explicit eviction in this module; lifetime is process-scoped. -- Timeout and cancellation are normalized across backends before result shaping: timeouts return error results with `details.timedOut`, while cancellations throw. +- Timeout shaping is backend-specific: local non-PTY and interactive-PTY timeouts return error results with `details.timedOut`; the client-bridge terminal creation/execution timeout paths throw `ToolError`. Non-timeout cancellations throw across these tool-call routes. ## Implementation files diff --git a/docs/collab.md b/docs/collab.md index a938cf8d4..9aada8422 100644 --- a/docs/collab.md +++ b/docs/collab.md @@ -92,7 +92,7 @@ Guests with a view-only link can read everything live — back-transcript, strea Everything that mutates the host session or machine is host-only: `/model`, `/compact`, `/resume`, `/branch`, bash (`!`), python (`$`), skills, etc. Guests keep a small local allowlist (`/dump`, `/export`, `/copy`, `/help`, `/hotkeys`, `/theme`, `/settings`, `/leave`, `/collab`, `/exit`, `/quit`). -Known v1 limit for guests: a turn already streaming when you join becomes visible from its next message boundary. +When a guest joins during an assistant turn, that in-flight turn appears on the first subsequent `message_update`: the guest synthesizes the missing `message_start` from the update's full accumulating message before forwarding the delta. If the host emits no further update for that turn after the guest joins, there is no update from which to synthesize the live component. The durable entry still reaches the replica's message state, but entry frames are intentionally not rendered, so that edge case can remain absent from the live TUI. ## Web client diff --git a/docs/compaction.md b/docs/compaction.md index 540f5f8fa..509ef0bad 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -329,9 +329,9 @@ Entries to summarize: B, C, D After navigation with summary: - ┌─ B ─ C ─ D ─ [summary of B,C,D] + ┌─ B ─ C ─ D (abandoned branch, unchanged) A ───┤ - └─ E ─ F (new leaf) + └─ E ─ F ─ [summary of B,C,D] (new leaf) ``` ### Preparation and token budget diff --git a/docs/computer-use.md b/docs/computer-use.md index d3454e879..e5c095091 100644 --- a/docs/computer-use.md +++ b/docs/computer-use.md @@ -45,7 +45,14 @@ The function input is: `code` runs with top-level `await` in a persistent, full-host-access Bun session. Window handles, screenshot frames, and recent AX references survive between calls. Available globals include `desktop`, `wait`, `assert`, `display`, `print`, `read`, `write`, and `tool.*`. -Use `read_only: true` for inspection. In that mode screenshots and AX reads work, while input, clipboard writes, and other mutation are rejected. Calls are serialized through one lazy worker. Aborting a run terminates the worker; the next call starts a fresh session and requires new handles/frames. +Use `read_only: true` to declare an inspection-only call for approval and to +block mutation through the `desktop` facade: screenshots and AX reads work, +while facade input and clipboard-write methods reject the call. This is **not a +sandbox**. The evaluated code still has the worker's full Bun/Node host access, +including `process`, `require`, and `fs`, so `read_only` does not prevent +mutation through arbitrary host APIs. Calls are serialized through one lazy +worker. Aborting a run terminates the worker; the next call starts a fresh +session and requires new handles/frames. ## Discover targets @@ -74,13 +81,13 @@ Window methods include: - `screenshot({ silent? })` - `click(x, y, { button?, count?, modifiers?, delivery? })` and `doubleClick(x, y)` -- `move(x, y, options?)`, `drag([[x, y], ...], options?)`, and `scroll(x, y, { dx?, dy?, delivery? })` +- `move(x, y)`, `drag([[x, y], ...], options?)`, and `scroll(x, y, { dx?, dy?, delivery? })` - `type(text, { delivery? })` and `press(chord, { delivery? })` - `raise()` The `desktop` object exposes the same screenshot and input surface for the all-displays composite. -Pixel coordinates always belong to the most recent screenshot of the same target. Coordinate input before that capture is rejected. A resized/closed target or changed display layout invalidates the frame; capture again instead of guessing. Screenshots display automatically and are also saved at full resolution; `{ silent: true }` suppresses display in loops. +Pixel coordinates always belong to the most recent screenshot of the same target. Coordinate input before that capture is rejected. A resized/closed target or changed display layout invalidates the frame; capture again instead of guessing. Screenshots display automatically and are also saved at the captured resolution, subject to `computer.maxWidth` / `computer.maxHeight` and any effective model-transport cap. When a capture is scaled, the tool reports both the saved capture dimensions and the native source dimensions. `{ silent: true }` suppresses display in loops. Input defaults to `delivery: "background"`, which avoids changing the user's focus, pointer, or window order. If the OS or application cannot target that event safely, the call throws `BackgroundUnavailable`. Use AX, or explicitly retry with `delivery: "foreground"`, which briefly activates the target and restores focus afterward. macOS keyboard delivery to one of several windows in the same app and all Wayland per-window native input require this fallback. diff --git a/docs/custom-tools.md b/docs/custom-tools.md index 274021c9f..415c10f2d 100644 --- a/docs/custom-tools.md +++ b/docs/custom-tools.md @@ -18,8 +18,8 @@ If you need the model to call code directly, use a custom tool. There are two active integration styles: 1. **SDK-provided custom tools** (`options.customTools`) - - Wrapped into agent tools via `CustomToolAdapter` or extension wrappers. - - Always included in the initial active tool set in SDK bootstrap. + - In unrestricted SDK bootstrap, converted to extension tool definitions, registered through a generated extension, and always included in the initial active tool set. + - In a restricted session (`restrictToolNames: true`), SDK-provided custom tools are excluded unless `allowRestrictedCustomTools: true`; opted-in tools are active only when their names also appear in `toolNames`. 2. **Filesystem-discovered modules via loader API** (`discoverAndLoadCustomTools` / `loadCustomTools`) - Exposed as library APIs in `packages/coding-agent/src/extensibility/custom-tools/loader.ts`. @@ -31,7 +31,7 @@ Model tool call flow LLM tool call │ ▼ -Tool registry (built-ins + custom tool adapters) +Tool registry (built-ins + registered custom definitions) │ ▼ CustomTool.execute(toolCallId, params, onUpdate, ctx, signal) @@ -148,15 +148,15 @@ execute(toolCallId, params, onUpdate, ctx, signal); - `ctx` includes `sessionManager`, `modelRegistry`, current `model`, `isIdle()`, `hasQueuedMessages()`, `abort()`, and optional `settings`, `fetch`, `localProtocolOptions`, and `autoApprove`. - `signal` carries cancellation and may be `undefined`. -`CustomToolAdapter` bridges this to the agent tool interface and forwards calls in the correct argument order. +The session bootstrap bridge converts custom tools to extension `ToolDefinition`s and forwards calls in the correct argument order. `CustomToolAdapter` remains available to library consumers that directly adapt a custom tool to the agent tool interface. -Tool definitions may also declare `strict`, `hidden`, `loadMode`, `deferrable`, `mcpServerName`, `mcpToolName`, `approval`, and `formatApprovalDetails`. Custom tools default to the `"discoverable"` load mode; use `"essential"` to keep a tool top-level. +Tool definitions may also declare `strict`, `hidden`, `loadMode`, `deferrable`, `mcpServerName`, `mcpToolName`, and `approval`. When `loadMode` is omitted, custom tool names default to `"discoverable"` except for the canonical essential built-in names (`read`, `write`, `bash`, `edit`, `glob`, `computer`, `eval`, `task`, `hub`, `learn`, and `manage_skill`), which default to `"essential"` so wrappers or re-registrations do not demote them. An explicit `loadMode` always wins; use `"essential"` to keep any other tool top-level. Although the public `CustomTool` type also declares `formatApprovalDetails`, the SDK/discovery bridge does not propagate that callback into the registered tool definition, so it cannot customize approval details on the normal integration paths. ## How tools are exposed to the model -- Tools are wrapped into `AgentTool` instances (`CustomToolAdapter` or extension wrappers). +- Session bootstrap wraps included SDK-provided and discovered custom tools as extension tool definitions; library consumers may instead use `CustomToolAdapter` directly. - They are inserted into the session tool registry by name. -- In SDK bootstrap, custom and extension-registered tools are force-included in the initial active set. +- In unrestricted SDK bootstrap, custom and extension-registered tools are force-included in the initial active set. Restricted sessions exclude SDK-provided custom tools unless `allowRestrictedCustomTools: true`, and expose an opted-in custom tool only when its name appears in `toolNames`. - CLI `--tools` currently validates only built-in tool names; custom tool inclusion is handled through discovery/registration paths and SDK options. ## Rendering hooks @@ -164,12 +164,14 @@ Tool definitions may also declare `strict`, `hidden`, `loadMode`, `deferrable`, Optional rendering hooks: - `renderCall(args, options, theme)` -- `renderResult(result, options, theme, args?)` +- `renderResult(result, options, theme)` + +The normal SDK and filesystem-discovery paths wrap custom tools as extensions. On those paths, `renderResult` receives only the three arguments above; the bridge does not forward the original tool arguments. The public `CustomTool` type retains an optional fourth `args` parameter for direct `CustomToolAdapter` consumers. Runtime behavior in TUI: - If hooks exist, tool output is rendered inside a `Box` container. -- `renderResult` receives `{ expanded, isPartial, spinnerFrame? }`. +- `renderResult` receives `{ expanded, isPartial, spinnerFrame? }` as its `options` argument. - Renderer errors are caught and logged; UI falls back to default text rendering. ## Session/state handling diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 3dd53414f..bdfa7cd9d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -241,11 +241,34 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth | `PI_OPENROUTER_RESPONSES` | Responses API is enabled unless set to `0`; `0` selects the OpenAI Completions route | | `UMANS_WEBSEARCH_PROVIDER` | Default Umans Anthropic web-search provider selection when not supplied explicitly | -### Gemini CLI compatibility +### Gemini CLI and Antigravity compatibility -| Variable | Default / behavior | -| -------------------------- | --------------------------------------------------------------- | -| `PI_AI_GEMINI_CLI_VERSION` | Overrides Gemini CLI user-agent version tag (`0.35.3` if unset) | +| Variable | Default / behavior | +| --------------------------- | --------------------------------------------------------------- | +| `PI_AI_GEMINI_CLI_VERSION` | Overrides Gemini CLI user-agent version tag (`0.46.0` if unset) | +| `PI_AI_ANTIGRAVITY_VERSION` | Overrides Antigravity hub user-agent version (`2.1.4` if unset) | + +### GitLab Duo + +| Variable | Default / behavior | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GITLAB_CLIENT_ID` | OAuth client ID. If unset, the bundled GitLab OAuth application client ID is used. | +| `GITLAB_REDIRECT_URI` | Exact OAuth redirect URI advertised to GitLab. If unset, the local callback uses `http://localhost:8080/callback`, with random-port fallback. Must use HTTP or HTTPS; loopback callbacks must use HTTP and bind the URI's host and port. | +| `GITLAB_DUO_NAMESPACE_ID` | Workflow namespace override. Runtime options take precedence; otherwise namespace/project discovery uses the current credentials and working directory. | +| `GITLAB_DUO_PROJECT_ID` | Workflow project override by ID. Runtime `projectId`, then runtime `projectPath`, take precedence; this variable takes precedence over `GITLAB_DUO_PROJECT_PATH`. | +| `GITLAB_DUO_PROJECT_PATH` | Workflow project override by path when no runtime project or `GITLAB_DUO_PROJECT_ID` is set. | +| `GITLAB_DUO_WORKFLOW_DEFINITION` | Workflow definition override; runtime `workflowDefinition` takes precedence. Defaults to `ambient`. | +| `GITLAB_DUO_WORKFLOW_TRACE` | Workflow tracing is enabled only when the value is exactly `1`. Each trace event is appended as one JSON object per line; trace write failures are ignored. | +| `GITLAB_DUO_WORKFLOW_TRACE_FILE` | Trace output path. The value is trimmed; unset or blank defaults to the absolute path obtained by resolving `../../../../.tmp/gitlab-duo-workflow-trace.log` from the provider module (in a source checkout, `/.tmp/gitlab-duo-workflow-trace.log`). Missing parent directories are created automatically. | + +`GITLAB_CLIENT_ID` and `GITLAB_REDIRECT_URI` affect OAuth login. The four routing/creation +overrides (`GITLAB_DUO_NAMESPACE_ID`, `GITLAB_DUO_PROJECT_ID`, +`GITLAB_DUO_PROJECT_PATH`, and `GITLAB_DUO_WORKFLOW_DEFINITION`) affect +`gitlab-duo-agent` Workflow namespace/project resolution or workflow creation; they +do not configure OAuth. The two trace variables above affect only local diagnostic +output. A non-loopback +redirect URI cannot be served directly by the local callback listener and +therefore completes through the paste-code path. ### OpenAI Codex responses (feature/debug controls) @@ -406,6 +429,36 @@ Python subprocess filtering denies common API keys and allows safe base variable | `OMP_NO_WEBP` | `1` or `true` (case-insensitive) disables WebP in image-resize format selection | | `MNEMOPI_EMBEDDING_MODEL` | Embedding-model override for mnemopi memory configuration when no explicit override is supplied | +### Hindsight memory backend + +`loadHindsightConfig()` resolves each supported environment override over the corresponding +`hindsight.*` setting and then its built-in default. String values are trimmed and an empty +string is ignored. Boolean values are case-insensitive: only `true`, `1`, and `yes` mean true; +any other defined value means false. Integer values use base-10 `parseInt`; non-numeric values +are ignored and the loader does not clamp the parsed integer. Enum values must exactly match +one of the listed lowercase values; invalid values are ignored. + +| Variable | Setting overridden | Accepted value / built-in default | +| ---------------------------------- | ------------------------------- | --------------------------------------------------------------------------------- | +| `HINDSIGHT_API_URL` | `hindsight.apiUrl` | Non-empty string; default `http://localhost:8888` | +| `HINDSIGHT_API_TOKEN` | `hindsight.apiToken` | Non-empty string; unset by default | +| `HINDSIGHT_BANK_ID` | `hindsight.bankId` | Non-empty string; unset by default, so the selected scoping mode derives the bank | +| `HINDSIGHT_BANK_MISSION` | `hindsight.bankMission` | Non-empty string; default empty string | +| `HINDSIGHT_RETAIN_MODE` | `hindsight.retainMode` | `full-session` or `last-turn`; default `full-session` | +| `HINDSIGHT_RECALL_BUDGET` | `hindsight.recallBudget` | `low`, `mid`, or `high`; default `mid` | +| `HINDSIGHT_AUTO_RECALL` | `hindsight.autoRecall` | Boolean; default `true` | +| `HINDSIGHT_AUTO_RETAIN` | `hindsight.autoRetain` | Boolean; default `true` | +| `HINDSIGHT_SCOPING` | `hindsight.scoping` | `global`, `per-project`, or `per-project-tagged`; default `per-project-tagged` | +| `HINDSIGHT_DEBUG` | `hindsight.debug` | Boolean; default `false` | +| `HINDSIGHT_RECALL_MAX_TOKENS` | `hindsight.recallMaxTokens` | Integer; default `1024` | +| `HINDSIGHT_RECALL_CONTEXT_TURNS` | `hindsight.recallContextTurns` | Integer; default `1` | +| `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `hindsight.recallMaxQueryChars` | Integer; default `800` | +| `HINDSIGHT_RETAIN_EVERY_N_TURNS` | `hindsight.retainEveryNTurns` | Integer; default `3` | +| `HINDSIGHT_REQUEST_TIMEOUT_MS` | `hindsight.requestTimeoutMs` | Integer milliseconds; default `30000` | +| `HINDSIGHT_REFLECT_TIMEOUT_MS` | `hindsight.reflectTimeoutMs` | Integer milliseconds; default `120000` | +| `HINDSIGHT_RECALL_TIMEOUT_MS` | `hindsight.recallTimeoutMs` | Integer milliseconds; default `30000` | +| `HINDSIGHT_RETAIN_TIMEOUT_MS` | `hindsight.retainTimeoutMs` | Integer milliseconds; default `60000` | + `PI_NO_PTY` is also set internally when CLI `--no-pty` is used. --- diff --git a/docs/extension-loading.md b/docs/extension-loading.md index eb37e7cc2..f5d0869ae 100644 --- a/docs/extension-loading.md +++ b/docs/extension-loading.md @@ -1,8 +1,8 @@ # Extension Loading (TypeScript/JavaScript Modules) -This document covers how the coding agent discovers and loads **extension modules** (`.ts`/`.js`) at startup. +This document covers how the coding agent discovers and loads extension modules at startup. Scanned native/configured directories auto-discover `.ts` and `.js`; explicitly named files and installed-plugin manifest entries may also use `.mjs` and `.cjs`. -It does **not** cover `gemini-extension.json` manifest extensions (documented separately). +It does **not** cover [`gemini-extension.json` manifest extensions](./gemini-manifest-extensions.md), which are documented separately. ## What this subsystem does @@ -54,6 +54,8 @@ After hook discovery, `discoverAndLoadExtensions()` appends extension entry poin Plugin extension entries come from package `omp.extensions` / `pi.extensions` manifests, including enabled feature entries. +Installed-plugin manifest resolution accepts explicit `.ts`, `.js`, `.mjs`, and `.cjs` files. For a manifest entry that names a directory, it recognizes `index.ts`, `index.js`, `index.mjs`, or `index.cjs`; extension-directory expansion uses the same four suffixes. This is broader than native and configured-directory auto-scanning, which remains limited to `.ts` and `.js`. + ### 4) Explicitly configured paths After plugin extension entries, configured paths are appended and resolved. @@ -146,7 +148,7 @@ For configured paths: ### If configured path is a file -It is used directly as a module entry candidate. +It is used directly as a module entry candidate. Explicit `.ts`, `.js`, `.mjs`, and `.cjs` files are supported. ### If configured path is a directory diff --git a/docs/extensions.md b/docs/extensions.md index 61ef34d2c..0e6534844 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -421,7 +421,7 @@ ACP installs an elicitation-bridged UI context (`createAcpExtensionUiContext` in For durable extension state: -1. Persist with `pi.appendEntry(customType, data)`. +1. Persist with `pi.appendEntry("com.example.my-extension.state", data)`. The `customType` namespace is global: use a package- or reverse-domain-qualified value and avoid the core-reserved values in the [`custom` session-entry reference](./session.md#custom). 2. Rebuild state from `ctx.sessionManager.getBranch()` on `session_start`, `session_branch`, `session_tree`. 3. Keep tool result `details` structured when state should be visible/reconstructible from tool result history. @@ -431,7 +431,10 @@ Example reconstruction pattern: pi.on("session_start", async (_event, ctx) => { let latest; for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type === "custom" && entry.customType === "my-state") { + if ( + entry.type === "custom" && + entry.customType === "com.example.my-extension.state" + ) { latest = entry.data; } } diff --git a/docs/gemini-manifest-extensions.md b/docs/gemini-manifest-extensions.md index 1ecdf13c2..7dcdd02c2 100644 --- a/docs/gemini-manifest-extensions.md +++ b/docs/gemini-manifest-extensions.md @@ -2,7 +2,7 @@ This document covers how the coding-agent discovers and parses Gemini-style manifest extensions (`gemini-extension.json`) into the `extensions` capability. -It does **not** cover TypeScript/JavaScript extension module loading (`extensions/*.ts`, `index.ts`, `package.json omp.extensions`), which is documented in `extension-loading.md`. +It does **not** cover TypeScript/JavaScript extension module loading (`extensions/*.ts`, `index.ts`, `package.json omp.extensions`), which is documented in [Extension Loading](./extension-loading.md). ## Implementation files diff --git a/docs/hooks.md b/docs/hooks.md index 477ae11f8..fbf764c32 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -48,7 +48,7 @@ The factory can: - register slash commands via `pi.registerCommand(...)` - register custom message renderers via `pi.registerMessageRenderer(...)` - run shell commands via `pi.exec(...)` and log through `pi.logger` -- author schemas/helpers with injected `pi.arktype`, `pi.zod`, legacy-compatible `pi.typebox`, and package exports via `pi.pi` +- use injected `pi.zod`, the legacy-compatible `pi.typebox`, and package exports via `pi.pi`; `pi.arktype` is the ArkType `Type` runtime, not the `type(...)` schema builder ## Discovery and loading diff --git a/docs/macos-signing-notarization.md b/docs/macos-signing-notarization.md index 485d99dfc..602f8e393 100644 --- a/docs/macos-signing-notarization.md +++ b/docs/macos-signing-notarization.md @@ -92,7 +92,7 @@ The App Store Connect API key is the one credential that **cannot** be minted from a CLI — it is the bootstrap credential for the API itself, and the `.p8` downloads exactly once. Everything else is local. -### Uploading (no value leaves disk) +### Uploading without printing secret values `scripts/ci-macos-upload-secrets.sh` validates the files (opens the `.p12` with your password, sanity-checks the `.p8`) and pipes each value to `gh secret set` diff --git a/docs/marketplace.md b/docs/marketplace.md index a497d0756..a1d0d510f 100644 --- a/docs/marketplace.md +++ b/docs/marketplace.md @@ -24,7 +24,7 @@ A **plugin** is a directory containing Claude/OMP plugin content such as skills, Enabled project-scoped installs shadow enabled user-scoped installs of the same plugin. A disabled project install does not shadow the user install. -On Linux after `omp config migrate`, setting `XDG_DATA_HOME` moves user marketplace/plugin data to `$XDG_DATA_HOME/omp/{marketplaces.json,plugins/}`. The `~/.omp` paths below are the non-XDG defaults. +On Linux and macOS, `omp config init-xdg` creates the XDG data, state, and cache roots; it does not move existing data. Once the relevant roots exist and `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` are set, new user marketplace/plugin state resolves under `$XDG_DATA_HOME/omp` (including `marketplaces.json` and `plugins/`). The `~/.omp` paths below are the non-XDG defaults. ## Commands @@ -215,7 +215,7 @@ Invalid catalog JSON or invalid required top-level fields reject the catalog. An - `/marketplace update [name]` refreshes catalogs only; it does not reinstall plugins. - `omp plugin upgrade name@marketplace` reinstalls every installed scope when `--scope` is omitted. `/marketplace upgrade name@marketplace`, uninstall, and enable/disable require `--scope user|project` when the plugin exists in both scopes. - Upgrading all plugins compares only catalog entries that declare `version`. Semver versions must be newer; non-semver versions are treated as changed when unequal. Per-plugin failures are skipped, so an all-plugin upgrade can partially succeed. -- `marketplace.autoUpdate` controls startup checks: `off`, `notify` (default), or `auto`. Catalogs older than 24 hours are refreshed best-effort before version checks. +- `marketplace.autoUpdate` controls startup checks: `off`, `notify` (default), or `auto`. Catalogs older than 24 hours are refreshed best-effort before version checks. Despite its name, current `notify` mode writes update availability only to the debug log; it does not show a user-facing notification. - Removing a marketplace removes its registry entry and catalog cache; it does not uninstall plugins already cached and registered. ## On-disk layout diff --git a/docs/mcp-server-tool-authoring.md b/docs/mcp-server-tool-authoring.md index a4b95c289..2d23f27da 100644 --- a/docs/mcp-server-tool-authoring.md +++ b/docs/mcp-server-tool-authoring.md @@ -8,7 +8,7 @@ This document explains how MCP server definitions become callable `mcp__*` tools Config sources (.omp/.claude/.cursor/.vscode/mcp.json, mcp.json, etc.) -> discovery providers normalize to canonical MCPServer -> capability loader dedupes by server name (higher provider priority wins) - -> loadAllMCPConfigs converts to MCPServerConfig + skips enabled:false + -> loadAllMCPConfigs applies user enablement overrides and suppresses disabled servers -> MCPManager connects/listTools (with auth/header/env resolution) -> manager best-effort loads resources/prompts and subscribes to resource updates when enabled -> MCPTool/DeferredMCPTool bridge exposes tools as mcp___ @@ -76,7 +76,7 @@ Key behavior: - transport inferred as `server.transport ?? (command ? "stdio" : url ? "http" : "stdio")` - `requestIdFormat` is preserved; omitted means numeric IDs -- disabled servers (`enabled === false`) and names in the user `disabledServers` list are dropped before connection +- names in the active-profile user `disabledServers` list are always suppressed; a server with `enabled === false` is suppressed unless the same user config names it in `enabledServers` - optional fields are preserved when present ### Environment expansion during discovery @@ -93,18 +93,30 @@ Invalid `enabled`/`timeout` values are ignored with warnings rather than failing ### OAuth credential injection -If config has: +For `http`/`sse` servers, an `auth: { type: "oauth", credentialId: "..." }` +block is optional. OMP honors an explicit arbitrary or legacy credential ID when +it resolves. A managed, profile-scoped +`mcp_oauth:profile::` ID is accepted only when its profile is +active and its URL matches the server's expanded or literal URL; a mismatch is +ignored. If the accepted explicit ID does not resolve—or if there is no `auth` +block—OMP looks for a credential under deterministic IDs derived from the +expanded and literal server URL. These URL-keyed credentials are scoped to the +active profile, so a shared, definition-only server entry can use each +profile's independently stored OAuth credential. -```ts -auth: { type: "oauth", credentialId: "..." } -``` +A case-insensitive, explicitly configured `Authorization` header suppresses +that URL-keyed fallback. `stdio` servers have no URL to bind: their explicit +arbitrary or legacy credential ID must resolve, and a URL-keyed, +profile-scoped ID is ignored. -and credential exists in auth storage: +When lookup succeeds: - `http`/`sse`: injects `Authorization: Bearer ` header - `stdio`: injects `OAUTH_ACCESS_TOKEN` env var -If credential lookup fails, manager logs a warning and continues with unresolved auth. +If no credential resolves, OMP connects without injecting an OAuth value. +Refresh or credential-resolution failures are logged; when possible, OMP +continues with the existing access token. ### Header/env value resolution @@ -135,12 +147,40 @@ Rules: - repeated underscores collapse - redundant `_` prefix in tool name is stripped once -This avoids many collisions, but not all. Different raw names can still sanitize to the same identifier (for example `my-server` and `my.server` both sanitize similarly), and registry insertion is last-write-wins. +Different raw names can still sanitize to the same identifier (for example +`my-server` and `my.server` both sanitize similarly). Before registry +insertion, `deduplicateMCPToolsByName()` chooses one deterministic winner by +lexicographically comparing the original `\0` origin +key. The losing origin is logged and omitted, so reconnect or discovery order +cannot change ownership. ### Schema mapping `tool-bridge.ts` passes each MCP `inputSchema` through `normalizeSchemaForMCP()` before registering it as a `CustomTool` schema. +### Outbound argument normalization + +Before either live or deferred tools send `tools/call`, the bridge normalizes +the call's arguments in this order: + +1. Non-object values, `null`, and arrays at the top level become an empty + argument object. +2. The harness-injected intent field `i` is removed unless the MCP tool's own + `inputSchema.properties` declares `i`. +3. For a property declared by the MCP schema but not listed in `required`, a + value of `undefined`, an empty string, or an empty non-array object is + omitted. Required properties, undeclared properties, `0`, `false`, `null`, + and arrays (including empty arrays) are preserved. +4. String values are walked recursively through nested objects and arrays. + A resolvable `local://` file URL becomes the real filesystem path that an + external MCP server can read. The original string remains when no active + local-file resolver exists or the URL denotes a directory/root rather than + a file; invalid, missing, or escaping local-file URLs fail during + normalization instead of reaching `tools/call`. + +Server authors should therefore validate against the normalized payload, not +assume that every field present in the model-generated call reaches the server. + ### Execution mapping `MCPTool.execute()` / `DeferredMCPTool.execute()`: @@ -214,8 +254,8 @@ For robust MCP authoring in this codebase: 1. Keep server names globally unique across all MCP-capable config sources. 2. Prefer names that remain distinct after MCP tool-name sanitization to avoid generated `mcp__` collisions. 3. Use explicit `type` to avoid accidental stdio defaults. -4. Treat `enabled: false` as hard-off: server is omitted from runtime connect set. -5. For OAuth configs, store a valid `credentialId`; otherwise auth injection is skipped. +4. Use the active-profile user `enabledServers` list when you need to override a discovered server's `enabled: false`; `disabledServers` always wins if the name appears in both lists. +5. For remote OAuth servers, a valid explicit `credentialId` is optional: a definition-only `http`/`sse` entry can use the active profile's credential bound to the same URL. Use an explicit `Authorization` header when that URL-keyed fallback must be suppressed. 6. If using command-based secret resolution (`!cmd`), verify command output is stable and non-empty. ## Implementation files diff --git a/docs/memory.md b/docs/memory.md index 23e15a0cc..ec3253747 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -1,8 +1,15 @@ # Autonomous Memory -When the local memory backend is enabled, the agent automatically extracts durable knowledge from past sessions and injects a compact summary into future sessions for the same project. Over time it builds a project-scoped memory store — technical decisions, recurring workflows, pitfalls — that carries forward without manual effort. +Oh My Pi supports four memory modes. Memory is disabled by default; select one backend via `/settings` or `config.yml`: -Disabled by default. Enable the local summary pipeline via `/settings` or `config.yml`: +| `memory.backend` | Storage and behavior | Guide | +| ---------------- | ---------------------------------------------------------------------- | ------------------------------------------------------- | +| `off` | No memory backend | — | +| `local` | Project-scoped summaries and lessons generated from persisted sessions | This page | +| `hindsight` | Remote, bank-scoped Hindsight memory | [Hindsight](#hindsight-remote-backend) | +| `mnemopi` | Local Mnemopi SQLite memory | [Mnemopi memory backend](./mnemosyne-memory-backend.md) | + +Enable the local summary pipeline: ```yaml memory: @@ -113,6 +120,28 @@ If the requested memory role is not configured, memory model resolution falls ba | `memories.fallbackTokenLimit` | `16000` | Model token budget used when the model has no finite declared context window | | `memories.summaryInjectionTokenLimit` | `5000` | Shared approximate token cap for the summary and captured lessons injected into the system prompt | +## Hindsight remote backend + +Hindsight requires a reachable [Hindsight](https://hindsight.vectorize.io/) server. The default endpoint is `http://localhost:8888`; set a token when the server requires authentication: + +```yaml +memory: + backend: hindsight +hindsight: + apiUrl: http://localhost:8888 + apiToken: ${HINDSIGHT_API_TOKEN} +``` + +`HINDSIGHT_*` environment variables override `hindsight.*` settings, which override built-in defaults. See the [complete Hindsight environment-variable table](./environment-variables.md#hindsight-memory-backend) for all 18 supported overrides, accepted values, parsing rules, precedence, and defaults. + +By default, Hindsight uses `per-project-tagged` scoping: writes go to a shared bank with a project tag, while recall includes project-tagged and untagged global memories. `per-project` isolates each working-directory project in its own bank; `global` uses one shared bank. An explicit `hindsight.bankId` selects the bank base. Changes to the bank ID, prefix, or scoping rebuild the primary session state so later operations use the new scope. + +The primary session recalls on its first model turn (`hindsight.autoRecall: true`) and automatically retains completed conversation turns every three user turns by default. `/memory enqueue` flushes queued tool retains and forces retention of the current session. At agent end, the primary state schedules cadence-based retention and flushes the retain queue; session disposal drains that queue before releasing the state. Request failures and configured timeouts are logged and leave the coding session usable. Subagents alias the parent's client, bank, and scope for explicit `recall`, `retain`, and `reflect` calls, but do not run their own automatic recall or retention. + +Recall is injected as background context, not instructions, and recalled memory is also available as extra context during compaction. Selecting Hindsight exposes `recall`, `retain`, and `reflect`; `memory_edit` is not available because upstream Hindsight memories are not edited through this backend. + +`/memory view`, `/memory stats`, `/memory diagnose`, and `/memory enqueue` operate through the active Hindsight state. `/memory clear` first drains pending retains, then clears only the local session state and recall cache. It **does not delete the server-side bank**; delete that bank with the Hindsight UI or API. + ## Key files - `packages/coding-agent/src/memories/index.ts` — pipeline orchestration, injection, clear/enqueue entry points (the `/memory` command routes here via `packages/coding-agent/src/memory-backend/local-backend.ts`) diff --git a/docs/mnemosyne-memory-backend.md b/docs/mnemosyne-memory-backend.md index 2defcea87..5b3974416 100644 --- a/docs/mnemosyne-memory-backend.md +++ b/docs/mnemosyne-memory-backend.md @@ -171,3 +171,17 @@ new Mnemopi({ - `/memory stats` and `/memory diagnose` render backend-specific bank statistics/diagnostics when the Mnemopi backend is active. - Subagents do not own separate Mnemopi retain loops; they alias the parent state when a parent Mnemopi state exists, and otherwise remain inert. - Backend startup is best-effort. If database/model initialization fails, the session continues with Mnemopi inert and logs a warning; memory tools then report that the backend is not initialized. + +## Shutdown and durability + +Normal interactive and print-mode exit uses a deliberately lighter path than `/memory enqueue`: + +1. The primary state retains the current transcript with new fact extraction disabled. +2. It flushes extractions that were already in flight, but does not run per-session sleep or full cross-session promotion. +3. Only after that drain settles does it close the owned SQLite bank handles; the embedding worker shuts down after state disposal because the drain may still use it. + +Aliased subagent states do not own or close the shared banks; the parent state owns final retention, flushing, and handle closure. + +Interactive and print exits give this drain 1.5 seconds. If the budget expires, shutdown detaches the in-flight drain and arranges for handles to close when it settles rather than racing writes against closed databases. The process may exit first. Working-memory rows already written remain durable, but promotion or embedding for the last few turns can remain incomplete; earlier turn retention performed at agent end is unaffected. + +`/memory enqueue` is the explicit stronger durability boundary: it forces retention, flushes pending extraction, and runs full sleep/consolidation across the owned banks. Use it before exit when the latest material must be promoted rather than relying on the bounded normal shutdown path. diff --git a/docs/models.md b/docs/models.md index 2e3050227..c837cf293 100644 --- a/docs/models.md +++ b/docs/models.md @@ -51,6 +51,7 @@ providers: disableStrictTools: false # set true for Anthropic-compatible endpoints that reject the strict field discovery: type: ollama + timeoutMs: 10000 # optional per-provider HTTP probe timeout in milliseconds modelOverrides: some-model-id: name: Renamed model @@ -123,11 +124,19 @@ Must define at least one of: - `disableStrictTools` - `modelOverrides` - `discovery` +- `remoteCompaction` ### Discovery +- `discovery.timeoutMs` overrides that provider's runtime HTTP probe timeout in milliseconds. It must be a positive finite number. - `discovery` requires provider-level `api`, except `discovery.type: proxy` (per-model wire auto-detected). +### Remote compaction + +`remoteCompaction` is independently sufficient for an override-only provider. +It supports `enabled`, `api`, `endpoint`, `model`, `v2StreamingEnabled`, +`v2Endpoint`, and `streamingEndpoint`. + ### Model value checks - `id` required @@ -163,7 +172,7 @@ ModelRegistry pipeline (on refresh): ### Provider-model cache and static fingerprint Cached per-provider model lists are persisted in the model-cache SQLite -database (current schema version 6) with a `static_fingerprint` column that +database (current schema version 12) with a `static_fingerprint` column that hashes the static catalog slice merged into the row. When `resolveProviderModels` skips the network fetch and the fingerprint of the in-memory static catalog matches the cached one, the cached rows are returned verbatim — diff --git a/docs/natives-build-release-debugging.md b/docs/natives-build-release-debugging.md index 82ad496e2..ed862cbce 100644 --- a/docs/natives-build-release-debugging.md +++ b/docs/natives-build-release-debugging.md @@ -367,7 +367,7 @@ When `pi-natives` is built inside the robomp orchestrator (`python/robomp/`), wo ### What is cached -The complete set of files in `packages/natives/native/` that are pure functions of the cache-key inputs: +The cache captures the following files from `packages/natives/native/` under the computed key. Correct reuse assumes the worktree contents of keyed paths match committed `HEAD`; because the key ignores uncommitted changes, a build from a dirty keyed path can otherwise be captured under and later reused from the unchanged key: - `pi_natives.-[-variant].node` (glob `pi_natives.*.node`) - `index.d.ts` @@ -430,4 +430,4 @@ Workspaces that hardlinked a `.node` before GC retain access via the kernel inod - Everything: `rm -rf /data/cache/pi-natives/*` (preserve the root so its setgid mode survives). - Stuck lock: `rm /data/cache/pi-natives//.lock` (only when no orchestrator process is touching the repo). -An automatic miss occurs only when committed `HEAD` changes under `crates/`, `Cargo.lock`, `Cargo.toml`, `rust-toolchain.toml`, or `packages/natives/`. Merely editing an uncommitted worktree does not change the key. +For a fixed target suffix, a committed `HEAD` change under `crates/`, `Cargo.lock`, `Cargo.toml`, `rust-toolchain.toml`, or `packages/natives/` produces an automatic miss. Changing platform/architecture, or `TARGET_VARIANT` on x64, also selects a different key. Merely editing an uncommitted worktree changes neither the `HEAD` hashes nor the key. diff --git a/docs/natives-media-system-utils.md b/docs/natives-media-system-utils.md index 10d114f90..7ce9100a7 100644 --- a/docs/natives-media-system-utils.md +++ b/docs/natives-media-system-utils.md @@ -59,7 +59,7 @@ Supported decode formats are whatever the compiled `image` crate supports for `I ### Snapcompact PNG rendering -`renderSnapcompactPng(text, options)` renders pre-normalized text on a bounded bitmap and asynchronously returns PNG bytes encoded as a one-byte/Latin-1 JavaScript string. `options.size` is required; optional controls include `font`, `cellWidth`, `cellHeight`, `variant`, `lineRepeat`, `stretch`, and `columns`. Output height hugs used rows and overflowing input is ignored. `snapcompactSupportedChars(font, chars)` returns only characters supported by the named bundled font. +`renderSnapcompactPng(text, options)` renders pre-normalized text on a bounded bitmap and asynchronously returns a **base64-encoded PNG string**. The N-API transport type is `Latin1String`, but the string contains base64 text rather than raw one-byte PNG data; base64-decode it before treating the result as PNG bytes. `options.size` is required; optional controls include `font`, `cellWidth`, `cellHeight`, `variant`, `lineRepeat`, `stretch`, and `columns`. Output height hugs used rows and overflowing input is ignored. `snapcompactSupportedChars(font, chars)` returns only characters supported by the named bundled font. ### HTML conversion (`html`) diff --git a/docs/natives-rust-task-cancellation.md b/docs/natives-rust-task-cancellation.md index 32f022131..c70af9fd9 100644 --- a/docs/natives-rust-task-cancellation.md +++ b/docs/natives-rust-task-cancellation.md @@ -38,7 +38,7 @@ This document describes how `crates/pi-natives` schedules native work and how ca - `CancelToken::new(timeout_ms, signal)` wraps the shared `pi_shell::cancel::CancelToken`, adding an optional JS `AbortSignal` bridge. - `CancelToken::heartbeat()` is cooperative cancellation for blocking loops. - `CancelToken::wait()` asynchronously waits for signal or timeout. - - `CancelToken::abort_token()` returns a token only when a shared abort flag already exists; `emplace_abort_token()` lazily installs that flag. `CancelToken::new` uses the latter to bridge a JS `AbortSignal` to `AbortReason::Signal`. + - `CancelToken::abort_token()` returns an abort handle backed by the shared flag when one already exists; without a flag, the handle is inert. `emplace_abort_token()` lazily installs the flag and returns a live handle. `CancelToken::new` uses the latter to bridge a JS `AbortSignal` to `AbortReason::Signal`. - `CancelToken::aborted()` provides a non-blocking signal/deadline check, and `into_core()` transfers the token to `pi-shell`. - `AbortToken::abort(reason)` lets external code request abort. Reasons are `Unknown`, `Timeout`, `Signal`, and `User`. diff --git a/docs/natives-text-search-pipeline.md b/docs/natives-text-search-pipeline.md index e508b7d36..c9010a532 100644 --- a/docs/natives-text-search-pipeline.md +++ b/docs/natives-text-search-pipeline.md @@ -156,6 +156,7 @@ Terminology follows `docs/natives-architecture.md`: - `astEdit(options)` returns replacement changes, per-file counts, searched/touched file counts, parse errors, and whether edits were applied. - `dryRun` defaults to true for edit options in the generated documentation. - Options include language override, path/glob/selector, strictness, limits, parse-error policy, `signal`, and `timeoutMs`. +- For `astGrep` and `astEdit`, a directory `path` uses the shared cache for candidate discovery with configured stale-empty rechecking; a direct file `path` returns that file without traversal or cache access. `astMatch` remains in-memory. These exports are direct native APIs used by tooling; they are not mediated by a TS wrapper in `packages/natives`. @@ -163,7 +164,7 @@ These exports are direct native APIs used by tooling; they are not mediated by a `pi-walker` owns traversal and cache policy. `crates/pi-natives/src/iofs.rs` contains only JavaScript-facing DTO conversion, error mapping, and the invalidation export. -The cache stores normalized relative entries (`path`, `fileType`, optional `mtime` and regular-file `size`) keyed by canonical search root plus the complete `WalkOptions` value, including hidden/gitignore, link-following, filtering, ordering, and detail policy. +The cache stores normalized relative entries (`path`, `fileType`, optional `mtime` and regular-file `size`) keyed by canonical search root plus the effective traversal-level `WalkOptions`: hidden/gitignore and directory-pruning policy, link following, metadata detail, traversal order/depth, root emission, directory-error handling, filesystem boundary, and cache mode. `WalkFilter` predicates, ranking, and result limits run after collection and do not independently partition the cache, so requests with different glob, file-type, size-threshold, or limit values can share an entry. A filter or rank that requires extra metadata can still promote the effective detail policy and thereby select a different key. Configuration is read from environment once: @@ -242,17 +243,17 @@ Text functions generally return deterministic transformed output; errors are lim ## Pure utility vs filesystem-dependent flows -| Flow | Filesystem access | Shared cache | Notes | -| ---------------------------- | ----------------- | ------------ | ---------------------------------------------------------- | -| `search` / `hasMatch` | No | No | regex on provided bytes/string only | -| `text` module functions | No | No | ANSI/width utilities only | -| `highlight` module functions | No | No | syntax + ANSI coloring only | -| `countTokens` | No | No | tokenization only | -| `astMatch` | No | No | in-memory syntax-aware match (no disk) | -| `astGrep` / `astEdit` | Yes | No | syntax-aware file search/edit | -| `glob` | Yes | Optional | directory scans + glob filtering | -| `fuzzyFind` | Yes | Optional | directory scans + fuzzy scoring | -| `grep` (file/dir path) | Yes | No | walker discovery + regex search, optional filters/callback | +| Flow | Filesystem access | Shared cache | Notes | +| ---------------------------- | ----------------- | ------------------------- | -------------------------------------------------------------------- | +| `search` / `hasMatch` | No | No | regex on provided bytes/string only | +| `text` module functions | No | No | ANSI/width utilities only | +| `highlight` module functions | No | No | syntax + ANSI coloring only | +| `countTokens` | No | No | tokenization only | +| `astMatch` | No | No | in-memory syntax-aware match (no disk) | +| `astGrep` / `astEdit` | Yes | Yes (directory discovery) | directory paths use cached traversal; a direct file path bypasses it | +| `glob` | Yes | Optional | directory scans + glob filtering | +| `fuzzyFind` | Yes | Optional | directory scans + fuzzy scoring | +| `grep` (file/dir path) | Yes | No | walker discovery + regex search, optional filters/callback | ## End-to-end lifecycle summary 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/plugin-manager-installer-plumbing.md b/docs/plugin-manager-installer-plumbing.md index 4bc1efe72..a9329bf91 100644 --- a/docs/plugin-manager-installer-plumbing.md +++ b/docs/plugin-manager-installer-plumbing.md @@ -42,7 +42,7 @@ omp plugin install name@marketplace / omp install name@marketplace ## On-disk model -User plugin state lives under the plugins data root (`~/.omp/plugins` by default; `$XDG_DATA_HOME/omp/plugins` on Linux after `omp config migrate` when `XDG_DATA_HOME` is set): +User plugin state lives under the plugins data root (`~/.omp/plugins` by default). On Linux and macOS, `omp config init-xdg` creates the XDG data, state, and cache roots but does not move existing data; after the relevant roots exist and the XDG variables are set, new user plugin state resolves under `$XDG_DATA_HOME/omp/plugins`: - `package.json` — dependency manifest used by `bun install`/`bun uninstall` for npm-installed plugins - `node_modules/` — installed npm packages plus link and marketplace-cache symlinks diff --git a/docs/porting-to-natives.md b/docs/porting-to-natives.md index 6d9857098..c18b3f6aa 100644 --- a/docs/porting-to-natives.md +++ b/docs/porting-to-natives.md @@ -110,7 +110,7 @@ For Promise-returning operations, use an async benchmark loop and await every ca Run the narrow scenario against the addon you just built. When diagnosing a candidate mismatch, inspect the candidate path reported by the loader: ```bash -bun -e 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const mod = require(process.argv[2]); console.log(Object.keys(mod).sort())' -- /path/to/pi_natives.[-variant].node +bun -e 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const mod = require(process.argv[1]); console.log(Object.keys(mod).sort())' -- /path/to/pi_natives.[-variant].node ``` Confirm the export and the package-version sentinel are present. Do not add optional consumer checks for a required export to conceal an artifact mismatch. diff --git a/docs/provider-streaming-internals.md b/docs/provider-streaming-internals.md index 967ee2059..729563e62 100644 --- a/docs/provider-streaming-internals.md +++ b/docs/provider-streaming-internals.md @@ -145,9 +145,10 @@ Observed behavior in current implementation: - malformed SSE framing or chunk JSON surfaces as an exception or stream `error` event - malformed Codex SSE JSON/framing throws from the local SSE reader -- provider wrapper converts failures into unified terminal `error` events -- no provider-specific resume/retry inside the stream function itself, except Codex websocket-to-SSE transport fallback before replay-unsafe output is emitted -- higher-level retries are handled in `AgentSession` auto-retry logic (message-level retry, not stream-chunk replay) +- providers do not resume from an individual malformed chunk. Depending on the provider and whether any replay-unsafe output has been emitted, a bounded provider-owned request retry may start a fresh attempt for transient transport or malformed-envelope failures. +- provider-owned recovery also includes bounded empty-completion retries (OpenAI Responses, OpenAI Completions, Anthropic, Google native/Vertex, Gemini CLI, and Ollama) and capability fallbacks such as retrying without rejected strict-tool fields +- Codex can fall back from websocket to SSE only before replay-unsafe output is emitted +- `AgentSession` separately handles message-level auto-retry; it does not replay a stream from the failed chunk ## Cancellation boundaries diff --git a/docs/providers.md b/docs/providers.md index ab244abdf..7f5850b76 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -366,4 +366,4 @@ disabledProviders: **A discovery provider name had no effect on models (or vice-versa).** The ID namespace is shared. `gemini`, `codex`, `claude`, `native`, and `agents` are discovery-source IDs; the Google model backend is `google`. Make sure you are disabling the right kind of provider. -**A custom `models.yml` provider does not load.** A YAML or schema error makes the registry skip the custom file. Validate the file with `omp models` (use `omp models find ` to scope it to one provider), and confirm each provider has a `baseUrl`, a valid `api`, and at least one model entry. An explicit `ollama`, `lm-studio`, or `llama.cpp` entry intentionally replaces built-in discovery for that ID. See [Model and Provider Configuration](./models.md). +**A custom `models.yml` provider does not load.** A YAML or schema error makes the registry skip the custom file. Validate the file with `omp models` (use `omp models find ` to scope it to one provider). A provider with custom `models` needs `baseUrl`, authentication (`apiKey`, unless `auth: none`), and `api` at provider level or on every model. A provider with no models is also valid when it defines at least one supported override (`baseUrl`, `headers`, `apiKey`, `auth: none`, `compat`, `disableStrictTools`, `remoteCompaction`, `modelOverrides`, or `discovery`). Discovery providers may omit `models`, but need provider-level `api` unless `discovery.type` is `proxy`. An explicit `ollama`, `lm-studio`, or `llama.cpp` entry intentionally replaces built-in discovery for that ID. See [Model and Provider Configuration](./models.md). diff --git a/docs/rpc.md b/docs/rpc.md index dce131bfc..500caf0b4 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -499,6 +499,21 @@ Extension runner errors are emitted separately as: `message_update` includes streaming deltas in `assistantMessageEvent` (text/thinking/toolcall deltas). +`agent_end` has this session-level shape (in addition to optional telemetry fields): + +```ts +{ + type: "agent_end"; + messages: AgentMessage[]; + isTerminal?: boolean; +} +``` + +`isTerminal: false` means maintenance or async delivery has scheduled more work, +so the session will resume before its true final settle. Treat an `agent_end` as +run completion only when `isTerminal !== false`; the field is optional so frames +from older runtimes, where it is absent, remain terminal-compatible. + ### Available commands `get_available_commands` returns `{ commands }`, and the same array is pushed @@ -536,7 +551,7 @@ This is the most important operational behavior. That means: - command acceptance != run completion -- agent turns complete via `agent_end` +- agent turns complete only on `agent_end` frames where `isTerminal !== false` - local-only prompts complete via `data.agentInvoked: false` on the response or via a later `prompt_result` ### While streaming @@ -784,7 +799,7 @@ stdout sequence (typical): { "id": "req_1", "type": "response", "command": "prompt", "success": true } { "type": "agent_start" } { "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "..." }, "message": { "role": "assistant", "content": [] } } -{ "type": "agent_end", "messages": [] } +{ "type": "agent_end", "messages": [], "isTerminal": true } ``` ### 2) Prompt during streaming with explicit queue policy @@ -830,7 +845,9 @@ stdin: { "type": "extension_ui_response", "id": "ui_7", "value": "feature/rpc-host" } ``` -## Notes on `RpcClient` helper +## Client libraries + +### TypeScript helper `packages/coding-agent/src/modes/rpc/rpc-client.ts` is a convenience wrapper, not the protocol definition. @@ -842,4 +859,17 @@ Current helper characteristics: - Supports host-owned custom tools via `setCustomTools()` and automatic handling of `host_tool_call` / `host_tool_cancel` - Wraps common protocol commands including OAuth `getLoginProviders()` / `login(...)`; use raw protocol frames for any surface not wrapped by the helper. -Use raw protocol frames if you need complete surface coverage. +### Python package + +The bundled [`omp-rpc`](../python/omp-rpc/pyproject.toml) distribution provides the process-backed Python client. Its import package is `omp_rpc`; the package API, typed commands and events, host-tool/host-URI helpers, and orchestration examples are maintained in the [`omp-rpc` README](../python/omp-rpc/README.md). + +```python +from omp_rpc import RpcClient + +with RpcClient(provider="anthropic", model="claude-sonnet-4-5") as client: + state = client.get_state() + turn = client.prompt_and_wait("Reply with just the word hello") + print(turn.require_assistant_text()) +``` + +By default, `RpcClient` starts `omp --mode rpc`; pass `command=[...]` to own the exact child command. It handles request correlation, typed notifications, v2 negotiation and chunk reassembly, message pagination, extension UI, and host-owned tools and URI schemes. The Python package owns that client API and process lifecycle; this document and `rpc-types.ts` remain the canonical wire contract. Use raw protocol frames when a client library does not wrap the surface you need. diff --git a/docs/sdk.md b/docs/sdk.md index b60bbc751..086fe2a69 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -18,9 +18,9 @@ available model, but prompting cannot. ## Entry points -`@oh-my-pi/pi-coding-agent` exports the SDK APIs from the package root (and also via `@oh-my-pi/pi-coding-agent/sdk`). +The package root, `@oh-my-pi/pi-coding-agent`, is the complete embedding surface. It includes `createAgentSession` and the focused `/sdk` exports, plus lower-level session, auth, model, mode, extension, and tool APIs. -Core exports for embedders: +Import these core embedding APIs from the package root: - `createAgentSession` - `SessionManager` @@ -32,6 +32,8 @@ Core exports for embedders: - Discovery helpers (`discoverExtensions`, `discoverSkills`, `discoverContextFiles`, `discoverPromptTemplates`, `discoverSlashCommands`, `discoverCustomTSCommands`, `discoverMCPServers`) - Tool factory surface (`createTools`, `BUILTIN_TOOLS`, tool classes) +The narrower `@oh-my-pi/pi-coding-agent/sdk` subpath exports `createAgentSession`, its option/result types, `Settings`, `AgentRegistry`, discovery and system-prompt helpers, workspace-tree helpers, selected extension/MCP/tool types, and selected tool classes/factories. It does **not** export `SessionManager`, `AuthStorage`, or `ModelRegistry`; import those three from the package root as the examples below do. + ## Quick start (auto-discovery defaults) ```ts @@ -232,6 +234,12 @@ const unsubscribe = session.subscribe((event) => { - `notice` - `goal_updated` +`agent_end` includes `messages`, optional telemetry fields, and +`isTerminal?: boolean`. When `isTerminal` is `false`, maintenance or async +delivery will resume the session before its true final settle. Subscribers that +use `agent_end` as a completion signal MUST wait for `isTerminal !== false`. +Treat an absent field as terminal for compatibility with older runtimes. + ## Prompt lifecycle `session.prompt(text, options?)` is the primary entry point. @@ -256,6 +264,29 @@ Related APIs: - `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })` - `abort()` +## `AgentSession` lifecycle and disposal + +Call `await session.dispose()` when the embedder is completely done with a session. `dispose()` starts disposal itself and is idempotent: repeated or concurrent calls receive the same teardown promise, so shutdown events and owned resources are not drained twice. + +`beginDispose()` is the synchronous admission barrier for wrappers that must await their own teardown before calling `dispose()`. Call it before the wrapper's first `await`; otherwise deferred work can enter the gap. It immediately marks the session disposed, cancels memory startup, title generation, and auto-learn capture, clears queued yield/asides, stops advisor runtime, detaches aside delivery, and rejects new eval executions. Deferred session work checks the disposed state and is dropped or skipped. `beginDispose()` is also idempotent, and the later `dispose()` call remains required to finish asynchronous cleanup. + +```ts +import type { AgentSession } from "@oh-my-pi/pi-coding-agent"; + +async function closeEmbeddedSession( + session: AgentSession, + closeHostInputAndUi: () => Promise, +): Promise { + session.beginDispose(); // no new deferred work may enter after this point + await closeHostInputAndUi(); + await session.dispose(); +} +``` + +During asynchronous disposal, the session records and synchronously flushes its exit diagnostic, emits `session_shutdown` once, stops extension fallback timers, aborts retries, compaction, and the active agent turn, and gives post-prompt and auto-learn work bounded time to settle. It then tears down session-owned async jobs, eval kernels, browser tabs, native computer sessions, MCP connections, advisor state, and memory state concurrently. These subsystem drains are best-effort and bounded where applicable; failures are logged rather than preventing the remaining subsystem cleanup. + +Only after work capable of appending session entries has settled does disposal clean up an empty moved session, close the `SessionManager`, close provider session state, disconnect the agent, and remove listeners. A failure from the final persistence cleanup or `SessionManager.close()` rejects the shared disposal promise; individual provider-session close failures are logged. + ## Tools and extension integration ### Built-ins and filtering diff --git a/docs/session-operations-export-share-fork-resume.md b/docs/session-operations-export-share-fork-resume.md index a9481a031..a1cce4f78 100644 --- a/docs/session-operations-export-share-fork-resume.md +++ b/docs/session-operations-export-share-fork-resume.md @@ -1,6 +1,6 @@ -# Session Operations: export, dump, share, fresh, fork, resume/continue +# Session Operations: export, dump, share, fresh, clear, fork, resume/continue -This document describes operator-visible behavior for session export/share/fork/resume operations as currently implemented. +This document describes operator-visible behavior for session export, sharing, conversation reset, lifecycle, fork, and resume operations as currently implemented. ## Implementation files @@ -19,7 +19,10 @@ This document describes operator-visible behavior for session export/share/fork/ | `/export [--themes] [path]` | Slash command (TUI/headless) | No | No | HTML file | | `--export [outputPath]` | CLI startup fast-path | No runtime session mutation | No active session; reads target file | HTML file | | `/share` | Slash command (TUI/headless) | No | No | Encrypted share link (gist or share server); temp HTML only for TUI custom handlers | +| `/new` | Interactive slash command | Yes (starts an empty conversation) | Switches identity; assigns a new transcript path in persistent mode | None | | `/fresh` | Slash command (TUI/headless) | Yes (provider-facing in-memory id/state only) | No; keeps current session file/header | None | +| `/clear` | Interactive slash command | Yes (clears live/model conversation context) | No; retains session identity, metadata, transcript file, and full on-disk history | Appends a durable `reset_boundary` | +| `/drop` | Interactive slash command | Yes (starts an empty conversation) | Attempts to delete the current persisted session and artifacts, then switches to a new one | None | | `/fork` | Interactive slash command | Yes (active session identity changes) | Creates new session file and switches current session to it (persistent mode only) | Copies artifact directory to new session namespace when present | | `--fork ` | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None | | `/resume [id\|@claude\|@codex]` | Interactive slash command | Yes (active in-memory state replaced) | Switches to a selected/matched session, or imports a selected foreign session | None | @@ -176,10 +179,39 @@ keeping the conversation you can see. - Leaves the local transcript, session file, and session identity unchanged, so nothing you have said or received is lost. -Because it keeps the current session file, `/fresh` differs from `/new` (start a -brand-new empty session) and `/drop` (delete the current session and start a new -one): only `/fresh` preserves the visible history while giving the provider a -clean slate. +Because it keeps both the visible and model-facing conversation, `/fresh` +differs from `/clear` (clear the live/model conversation in place), `/new` +(start a brand-new empty session), and `/drop` (attempt to delete the current +session and start a new one). Only `/fresh` preserves the existing conversation +while giving the provider stream state a clean slate. + +## Clear + +Interactive `/clear` clears the current conversation context in place. It is +available only in the TUI and is rejected while a response is streaming or a +foreground bash/Python execution is running. If compaction is active, the +command aborts it and waits for it to stop before resetting. + +`AgentSession.resetSessionContext()`: + +- Drops live messages, queued steer/follow-up turns, pending tool calls, error + state, checkpoint/rewind and deferred tool state, and session-stop + continuation state. It also cancels this agent's queued continuation work and + async bash/task jobs. +- Rotates provider-side session state, re-primes advisors, invalidates + append-only model context, and resets memory promotion so the next turn + rebuilds from the base system prompt and current project instructions. +- Retains the session id, title, cwd, model, settings, active plan path, and + transcript file. +- Appends a durable `reset_boundary`. The collapsed live transcript and rebuilt + model context begin after the latest boundary, while the JSONL transcript and + full-transcript export retain the pre-reset history on disk. + +The TUI clears its rendered transcript after a successful clear. This differs +from `/fresh`, which rotates provider stream state without clearing the +conversation; `/new`, which creates a new session identity and transcript file; +and `/drop`, which attempts to delete the old persisted session before starting +a new one. ## Fork @@ -337,7 +369,7 @@ These callbacks are observational; they do not cancel switch/fork. - `/fork` is blocked while streaming (user must wait/abort current response first). - `/resume` selector can be cancelled by user closing selector. -- Cross-project `--resume ` can be cancelled by declining fork prompt. +- Cross-project `--resume ` can be cancelled by declining the missing-directory move/re-root prompt. - `/share` has a UI abort path (`Share cancelled`); the upload itself is not killed mid-flight. ## Non-persistent (in-memory) session behavior @@ -355,3 +387,8 @@ When session manager is created with `SessionManager.inMemory()` (`--no-session` - `SelectorController.handleResumeSession()` does not check the boolean result from `session.switchSession(...)`; a hook-cancelled switch can still proceed through UI "Resumed session" repaint/status path. - `/share` custom-share failures do not degrade to the default encrypted share flow; they terminate the TUI command with an error. - `/export` argument tokenization does not preserve quoted paths with spaces. +- `/drop` treats deletion as best-effort: it attempts to delete the current + session JSONL and artifact directory, logs any deletion failure, and still + creates and switches to a new session. A failed or partial deletion can leave + the old session or its artifacts on disk, so `/drop` is not a guaranteed + erasure boundary. diff --git a/docs/session.md b/docs/session.md index eff9e5fc3..fcc6d350b 100644 --- a/docs/session.md +++ b/docs/session.md @@ -241,11 +241,11 @@ If branching from root (`branchFromId === null`), `fromId` is the literal string ### `reset_boundary` -A payload-free marker appended by `/reset`. The collapsed live transcript and rebuilt model context begin after the latest applicable boundary; full-history transcript export still retains entries before it. +A payload-free marker appended by `/clear`. The collapsed live transcript and rebuilt model context begin after the latest applicable boundary; full-history transcript export still retains entries before it. ### `custom` -Extension state persistence; ignored by `buildSessionContext`. +Opaque, non-LLM records owned by core subsystems or extensions. `buildSessionContext` does not directly turn them into model messages, but subsystem-specific replay code can consume `customType` values to restore runtime state or diagnose an interrupted turn. ```json { @@ -253,11 +253,25 @@ Extension state persistence; ignored by `buildSessionContext`. "id": "f1a2b3c4", "parentId": "e1f2a3b4", "timestamp": "2026-02-16T10:25:00.000Z", - "customType": "my-extension", + "customType": "com.example.my-extension.state", "data": { "state": 1 } } ``` +Current core-owned values include: + +| `customType` | `data` schema | Writer and consumer | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tool_execution_start` | `{ toolCallId: string, toolName: string, startedAt: string, args?: { command?: string, path?: string }, intent?: string }` | `AgentSession` writes a marker immediately before a tool implementation starts. Exit diagnostics combine it with assistant tool calls and tool results to reconstruct calls left pending. Argument summaries are truncated projections; older full argument objects are accepted on read. | +| `session_exit` | `{ reason: string, kind: "normal" \| "signal" \| "fatal" \| "process_exit", recordedAt: string, pendingToolCalls?: Array<{ toolCallId?: string, toolName: string, args?: unknown, intent?: string, assistantTimestamp?: number, startedAt?: string }> }` | Normal disposal and postmortem teardown record the exit when the session has assistant history or pending tool calls. The writer immediately calls `flushSync()` so a subsequent process can inspect the last durable turn; a flush failure is logged. Resume diagnostics consume the latest valid record. | +| `user_todo_edit` | `{ phases: TodoPhase[] }` | SDK/UI todo editing persists the complete phase snapshot. Todo restoration scans backward for the latest snapshot (or a successful `todo` tool result) and restores its phases. | +| `vibe-session-lifecycle` | Version-1 event with `{ version: 1, id, ownerId, parentSessionId, action, ... }`; `spawn` adds `cli`, `agent`, `childSessionFile`, and `createdAt`; turn events add `turn`; tombstone events add `reason`. | Vibe runtime persists and replays child spawn, turn-started/settled, tombstone, and tombstone-revoked transitions to recover owned child sessions and in-flight state. Invalid or out-of-scope events are ignored. | +| `autoresearch-control` | `{ mode: "on" \| "off" \| "clear", goal?: string }` | The built-in autoresearch command writes mode/goal changes, and experiment-limit shutdown writes `mode: "off"`. `reconstructControlState()` replays valid records on resume to restore whether autoresearch is active and its goal; `clear` removes the goal. | + +On resume, a valid latest `session_exit` after a non-terminal conversation tail causes the loader to append a synthetic assistant message with `stopReason: "aborted"` and rebuild the display/agent context. A normal exit only triggers that transition when it recorded pending tool calls; abnormal exit kinds can trigger it without that list. This prevents the restored transcript from presenting an interrupted turn as still live. + +The strings in the table are reserved for their core consumers. Extensions MUST NOT use them. Use a namespaced identifier such as a reverse-domain or package-qualified name for extension records; a collision can cause core replay logic to interpret extension data as lifecycle state. Unknown namespaced values remain opaque to core session-context reconstruction. + ### `custom_message` Extension-provided message that does participate in LLM context. `content` can be a string or text/image content blocks, and `attribution` records whether the user or agent initiated it. @@ -453,10 +467,10 @@ Completed entries update memory and are handed to file/memory storage synchronou Before persisting entries: -- Strings over 500,000 characters are truncated with `"[Session persistence truncated large content]"`, except signed/encrypted provider blocks and signature fields, which must remain byte-exact for replay. +- Strings over 500,000 characters are truncated with `"[Session persistence truncated large content]"`, except signed/encrypted provider blocks, signature fields, and complete Anthropic native web-search history blocks, which must remain byte-exact for replay. - Transient `jsonlEvents` is removed. - If an object has both string `content` and numeric `lineCount`, line count is recomputed after truncation. -- Base64 image payloads at least 1024 characters are content-addressed in the blob store and replaced with `blob:sha256:`. This includes image content blocks, image-data payloads/URLs, and image-generation results. +- Image data URLs in `image_url` fields are always content-addressed in the blob store and replaced with `blob:sha256:`, regardless of length. Other base64 image payloads are externalized at 1,024 characters: image content/data payloads and image-generation results. - Redundant OpenAI Responses `thinkingSignature` copies are omitted when the authoritative reasoning item already exists in `providerPayload`. On load, persisted blob references are resolved back to the inline payload shapes expected by downstream transports. diff --git a/docs/settings.md b/docs/settings.md index 961a07f5a..4f1791311 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -199,7 +199,7 @@ tools: disabledProviders: - anthropic - openai - - gemini + - google # /.omp/config.yml tools: @@ -302,7 +302,7 @@ Only string values are kept; malformed scoped entries are ignored. Path scoping | Entry kind | Example ids | Effect | | ----------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Model providers | `anthropic`, `openai`, `gemini`, `groq`, `ollama`, `openrouter` | Removes those backends from model selection, even when credentials are available. See [Providers](./providers.md). | +| Model providers | `anthropic`, `openai`, `google`, `groq`, `ollama`, `openrouter` | Removes those backends from model selection, even when credentials are available. See [Providers](./providers.md). | | Discovery sources | `native`, `claude`, `codex`, `gemini`, `github`, `opencode`, `cursor`, `agents-md` | Stops that source from contributing context files, MCP servers, commands, skills, hooks, tools, prompts, or settings. See [Context files](./context-files.md). | Most provider-control use cases list model provider ids. Disabling the `claude` discovery source is different from disabling the `anthropic` model provider — one stops Claude-format config discovery, the other stops the Anthropic model backend. @@ -335,7 +335,7 @@ modelRoles: default: anthropic/claude-sonnet-4-5 smol: openai/gpt-4.1-mini slow: anthropic/claude-opus-4-5:high - vision: gemini/gemini-3-pro-preview + vision: google/gemini-3.1-pro-preview plan: anthropic/claude-opus-4-5 advisor: anthropic/claude-sonnet-4-5:medium @@ -408,21 +408,21 @@ thinkingBudgets: A value of `-1` means "use the provider/model default" — `omp` does not send that parameter. -| Key | Type | Default | Notes | -| ------------------- | ------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `temperature` | number | `-1` | Sampling temperature. | -| `topP` | number | `-1` | Nucleus sampling. | -| `topK` | number | `-1` | Top-K sampling. | -| `minP` | number | `-1` | Minimum-probability cutoff. | -| `presencePenalty` | number | `-1` | Presence penalty. | -| `repetitionPenalty` | number | `-1` | Repetition penalty. | -| `textVerbosity` | enum | `medium` | `low`, `medium`, `high`. Sent as response verbosity by OpenAI Responses and Codex transports. | -| `tier.openai` | enum | `none` | `none`, `auto`, `default`, `flex`, `scale`, `priority`. Sent as `service_tier` for OpenAI / OpenAI-Codex and OpenAI-family OpenRouter models. | -| `tier.anthropic` | enum | `none` | `none`, `priority`. `priority` realizes fast mode on supported direct Claude models (ignored on Bedrock/Vertex and via OpenRouter). | -| `tier.google` | enum | `none` | `none`, `flex`, `priority`. Gemini API sends it in the body; Vertex sends `priority` via header (`flex` is a no-op on Vertex). | -| `tier.subagent` | enum | `inherit` | `inherit`, `none`, `auto`, `default`, `flex`, `scale`, `priority`. Applied to the spawned model's family; `inherit` tracks the main agent. | -| `tier.advisor` | enum | `none` | `inherit`, `none`, `auto`, `default`, `flex`, `scale`, `priority`. Applied to the advisor model's family. | -| `personality` | enum | `default` | `default`, `friendly`, `pragmatic`, `none`. | +| Key | Type | Default | Notes | +| ------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `temperature` | number | `-1` | Sampling temperature. | +| `topP` | number | `-1` | Nucleus sampling. | +| `topK` | number | `-1` | Top-K sampling. | +| `minP` | number | `-1` | Minimum-probability cutoff. | +| `presencePenalty` | number | `-1` | Presence penalty. | +| `repetitionPenalty` | number | `-1` | Repetition penalty. | +| `textVerbosity` | enum | `medium` | `low`, `medium`, `high`. Sent as response verbosity by OpenAI Responses and Codex transports. | +| `tier.openai` | enum | `none` | `none`, `auto`, `default`, `flex`, `scale`, `priority`. Sent as `service_tier` for OpenAI / OpenAI-Codex and OpenAI-family OpenRouter models. Launch with `--service-tier ` for a one-session OpenAI override; the flag is not persisted (`none` omits `service_tier`). | +| `tier.anthropic` | enum | `none` | `none`, `priority`. `priority` realizes fast mode on supported direct Claude models (ignored on Bedrock/Vertex and via OpenRouter). | +| `tier.google` | enum | `none` | `none`, `flex`, `priority`. Gemini API sends it in the body; Vertex sends `priority` via header (`flex` is a no-op on Vertex). | +| `tier.subagent` | enum | `inherit` | `inherit`, `none`, `auto`, `default`, `flex`, `scale`, `priority`. Applied to the spawned model's family; `inherit` tracks the main agent. | +| `tier.advisor` | enum | `none` | `inherit`, `none`, `auto`, `default`, `flex`, `scale`, `priority`. Applied to the advisor model's family. | +| `personality` | enum | `default` | `default`, `friendly`, `pragmatic`, `none`. | ### Retry and fallback @@ -459,17 +459,22 @@ retry: google-antigravity/*: - google/* - google-vertex/* + +providers: + anthropic: + serverSideFallback: false ``` -| Key | Type | Default | Notes | -| ---------------------------- | ------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `retry.enabled` | boolean | `true` | Retry transient provider errors. | -| `retry.maxRetries` | number | `10` | Max retries per request. | -| `retry.baseDelayMs` | number | `500` | Initial backoff. | -| `retry.maxDelayMs` | number | `300000` | Backoff ceiling (5 min). | -| `retry.modelFallback` | boolean | `true` | Fall back to another model when one is unavailable. | -| `retry.fallbackChains` | record | `{}` | Maps roles, model selectors, or `provider/*` wildcards to ordered fallback selectors. Keys containing `/` are model-oriented and win over roles: `provider/model-id` matches that exact model, `provider/*` matches every model of the provider. A `provider/*` _entry_ keeps the failing model's id and swaps the provider. The `default` chain covers every assigned role without its own chain. Unknown models/providers or malformed chains are reported as config warnings at startup. | -| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry` returns to the primary model once its suppression window ends; `never` stays on the fallback until switched manually. | +| Key | Type | Default | Notes | +| ---------------------------------------- | ------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `retry.enabled` | boolean | `true` | Retry transient provider errors. | +| `retry.maxRetries` | number | `10` | Max retries per request. | +| `retry.baseDelayMs` | number | `500` | Initial backoff. | +| `retry.maxDelayMs` | number | `300000` | Backoff ceiling (5 min). | +| `retry.modelFallback` | boolean | `true` | Fall back to another model when one is unavailable. | +| `retry.fallbackChains` | record | `{}` | Maps roles, model selectors, or `provider/*` wildcards to ordered fallback selectors. Keys containing `/` are model-oriented and win over roles: `provider/model-id` matches that exact model, `provider/*` matches every model of the provider. A `provider/*` _entry_ keeps the failing model's id and swaps the provider. The `default` chain covers every assigned role without its own chain. Unknown models/providers or malformed chains are reported as config warnings at startup. | +| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry` returns to the primary model once its suppression window ends; `never` stays on the fallback until switched manually. | +| `providers.anthropic.serverSideFallback` | boolean | `false` | Opt in to Anthropic's `server-side-fallback-2026-06-01` beta. Only direct `anthropic` provider requests using the `anthropic-messages` API for Claude Fable or Mythos models are eligible. On an Anthropic safety-classifier block, the provider may retry server-side with `claude-opus-4-8`; every other provider, API, and model is unaffected. | When the active model keeps failing (429s, quota walls, provider outages) and `retry.modelFallback` is on, the session picks the chain that owns the failing model, by specificity: an exact `provider/model-id` key, then a `provider/*` wildcard, then the current role's chain, then `default`. It skips models whose selectors are still cooling down and switches for the rest of the turn. Subagents get their own per-spawn chains when their agent definition lists multiple model patterns — the first resolvable pattern is primary and the rest become its fallbacks; there is no `agent:` key in `fallbackChains`. @@ -477,6 +482,7 @@ When the active model keeps failing (429s, quota walls, provider outages) and `r ```yaml tools: + format: auto approvalMode: yolo # default approval: bash: prompt @@ -485,17 +491,18 @@ tools: intentTracing: true ``` -| Key | Type | Default | Notes | -| ------------------------------ | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tools.approvalMode` | enum | `yolo` | `always-ask` (auto-approve read-only), `write` (auto-approve read + workspace-write), `yolo` (auto-approve all tiers). `--approval-mode` and `--auto-approve`/`--yolo` override per run. | -| `tools.approval` | record | `{}` | Per-tool policy keyed by tool name; each value is `allow`, `deny`, or `prompt`. e.g. `omp config set tools.approval '{"bash":"prompt"}'`. | -| `tools.maxTimeout` | number | `0` | Max tool runtime in seconds; `0` = no cap. | -| `tools.intentTracing` | boolean | `true` | Record per-call intent strings. | -| `tools.outputMaxColumns` | number | `768` | Per-line byte cap for streaming output; `0` disables. | -| `tools.artifactSpillThreshold` | number | `50` | KB of tool output above which output spills to an artifact. | -| `tools.artifactHeadBytes` | number | `20` | KB of head kept inline on spill; `0` = tail-only. | -| `tools.artifactTailBytes` | number | `20` | KB of tail kept inline on spill. | -| `tools.artifactTailLines` | number | `500` | Max tail lines kept inline on spill. | +| Key | Type | Default | Notes | +| ------------------------------ | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tools.format` | enum | `auto` | Tool wire format: `auto`, `native`, `glm`, `hermes`, `kimi`, `xml`, `anthropic`, `deepseek`, `harmony`, `qwen3`, `gemini`, `gemma`, or `minimax`. `native` always uses provider-native tool calls. `auto` also uses native calls unless the selected model explicitly has `supportsTools: false`; then it selects the model-family owned dialect, falling back to GLM when no specific family dialect is known. Other values force that owned in-band dialect. `xml` is the [generic XML format](./toolconv/xml.md); `minimax` is the [MiniMax format](./toolconv/minimax.md). Applies on session start. See [GLM](./toolconv/glm-4.5.md), [Qwen3/Hermes](./toolconv/qwen3.md), [Kimi](./toolconv/kimi-k2.md), [Anthropic](./toolconv/anthropic.md), [DeepSeek](./toolconv/deepseek.md), [Harmony](./toolconv/harmony.md), [Gemini](./toolconv/gemini.md), and [Gemma](./toolconv/gemma.md). | +| `tools.approvalMode` | enum | `yolo` | `always-ask` (auto-approve read-only), `write` (auto-approve read + workspace-write), `yolo` (auto-approve all tiers). `--approval-mode` and `--auto-approve`/`--yolo` override per run. | +| `tools.approval` | record | `{}` | Per-tool policy keyed by tool name; each value is `allow`, `deny`, or `prompt`. e.g. `omp config set tools.approval '{"bash":"prompt"}'`. | +| `tools.maxTimeout` | number | `0` | Max tool runtime in seconds; `0` = no cap. | +| `tools.intentTracing` | boolean | `true` | Record per-call intent strings. | +| `tools.outputMaxColumns` | number | `768` | Per-line byte cap for streaming output; `0` disables. | +| `tools.artifactSpillThreshold` | number | `50` | KB of tool output above which output spills to an artifact. | +| `tools.artifactHeadBytes` | number | `20` | KB of head kept inline on spill; `0` = tail-only. | +| `tools.artifactTailBytes` | number | `20` | KB of tail kept inline on spill. | +| `tools.artifactTailLines` | number | `500` | Max tail lines kept inline on spill. | Individual built-in tools are toggled by their own keys, e.g. `bash.enabled`, `launch.enabled`, `eval.py`, `eval.js`, `glob.enabled`, `grep.enabled`, `fetch.enabled`, `browser.enabled`, `computer.enabled`, `astEdit.enabled`, `astGrep.enabled`, and `web_search.enabled`. The `inspect_image` tool is controlled by the tri-state `inspect_image.mode` (`auto`|`on`|`off`, default `auto`): `auto` exposes it only when the active model lacks native image input, and the `/vision` slash command overrides the mode per session. diff --git a/docs/skills/authoring-extensions.md b/docs/skills/authoring-extensions.md index 94133e375..6222110d4 100644 --- a/docs/skills/authoring-extensions.md +++ b/docs/skills/authoring-extensions.md @@ -81,7 +81,7 @@ omp loads extension modules from these sources: - `/.omp/extensions/` - `~/.omp/agent/extensions/` - legacy extension paths listed in `.omp/settings.json#extensions` or `~/.omp/agent/settings.json#extensions` -2. Installed plugins under `~/.omp/plugins/node_modules` (`omp plugin install` npm/git specs, or `omp plugin link`) via their `omp.extensions`/`pi.extensions` manifests. Marketplace cache installs do not feed extension modules — they surface skills/commands/hooks/tools/MCP only. +2. Enabled installed plugins under `~/.omp/plugins/node_modules` or a project plugin root — including npm, marketplace, and `omp plugin link` installs — via their `omp.extensions`/`pi.extensions` manifests. 3. Explicit configured paths passed by the CLI (`omp --extension ./my-ext.ts`, also `-e`; `--hook` is treated as an alias) and by the `extensions:` setting in config. The runtime de-duplicates by resolved absolute path — first seen wins. @@ -131,6 +131,8 @@ Multiple entry points are supported: } ``` +Installed-plugin manifest entries may be `.ts`, `.js`, `.mjs`, or `.cjs`; a manifest entry naming a directory resolves `index.ts`, `index.js`, `index.mjs`, or `index.cjs`. Automatic scanning of native/configured extension directories remains limited to `.ts` and `.js`. + ## Registering commands ```ts @@ -228,10 +230,10 @@ Extensions are a strict superset of hooks. New authoring should use `ExtensionAP ## Debugging -omp writes structured logs to a rotating file under `~/.omp/logs/` (debug level is always on; nothing is written to the console, which would corrupt the TUI). Tail today's log to see extension load diagnostics: +omp writes structured logs under the active state root's `logs/` directory (by default `~/.omp/logs/`; debug level is always on, and nothing is written to the console because that would corrupt the TUI). Each filename includes the process ID. Tail today's default-profile logs to see extension load diagnostics: ``` -tail -f ~/.omp/logs/omp.$(date +%F).log +tail -f ~/.omp/logs/omp.$(date +%F).*.log ``` Failed extension loads are logged with their path and error. Loaded extensions may also emit their own debug logs via `pi.logger`. diff --git a/docs/skills/authoring-hooks.md b/docs/skills/authoring-hooks.md index fdf015098..386939efd 100644 --- a/docs/skills/authoring-hooks.md +++ b/docs/skills/authoring-hooks.md @@ -21,7 +21,7 @@ export default function myHook(omp: HookAPI): void { } ``` -The default export must be a plain synchronous function (not an async function or class). It receives a `HookAPI` instance and must register all handlers synchronously during execution. +The default export must be a function (not a class). It receives a `HookAPI` instance and should register handlers during factory execution; the loader awaits a returned promise, so asynchronous initialization is accepted. Alternatively, using `ExtensionAPI` (preferred): diff --git a/docs/skills/authoring-marketplaces.md b/docs/skills/authoring-marketplaces.md index b893eec6e..64d2dc68f 100644 --- a/docs/skills/authoring-marketplaces.md +++ b/docs/skills/authoring-marketplaces.md @@ -230,7 +230,7 @@ omp plugin install name@marketplace-name Scope behavior: -- **user** (default) — installed in the user plugins data root's `installed_plugins.json` (`~/.omp/plugins/installed_plugins.json`, or `$XDG_DATA_HOME/omp/plugins/installed_plugins.json` on migrated Linux XDG setups), available in all projects +- **user** (default) — installed in the user plugins data root's `installed_plugins.json` (`~/.omp/plugins/installed_plugins.json` by default), available in all projects. On Linux and macOS, `omp config init-xdg` creates (but does not migrate data into) the XDG roots; once the relevant roots exist and the XDG variables are set, new user state uses `$XDG_DATA_HOME/omp/plugins/installed_plugins.json`. - **project** — installed in `/.omp/plugins/installed_plugins.json`, available only in that project An enabled project-scoped install shadows an enabled user-scoped install of the same `name@marketplace` ID. A disabled project copy leaves the user copy active. diff --git a/docs/slash-command-internals.md b/docs/slash-command-internals.md index 6ebcd10bc..33ca1ffde 100644 --- a/docs/slash-command-internals.md +++ b/docs/slash-command-internals.md @@ -265,3 +265,9 @@ TUI and ACP/RPC dispatch the shared built-in registry before `session.prompt(... - native commands: fatal parse error bubbles - non-native commands: warning + fallback key/value parse - Extension/custom command handler exceptions are caught and reported via extension error channel (or logger fallback for custom commands without extension runner), and treated as handled (no unintended fallback execution). + +## 10) Built-in command note: `/pause` + +`/pause` is available only in the interactive TUI. It engages a process-global gate for the main agent, in-process subagents, and the advisor. Each agent parks at its next safe boundary: in-flight calls finish, nothing is aborted, and no new work starts until the gate is released. + +From the pause screen, press Esc, Enter, Space, or Ctrl+C to resume. Ctrl+C resumes rather than aborting any agent. diff --git a/docs/system-prompt-customization.md b/docs/system-prompt-customization.md index 66102db7f..3fd66ede7 100644 --- a/docs/system-prompt-customization.md +++ b/docs/system-prompt-customization.md @@ -115,6 +115,13 @@ If the message has no concrete task, output exactly `none`. `TITLE_SYSTEM.md` uses the same project-first, config-base discovery and no-ancestor-walk behavior. When absent, OMP uses its bundled title prompt. The override is used for both initial automatic titles and replan-driven title refreshes. +Generated title output has an enforced normalization contract even with a +custom prompt. OMP considers only the first trimmed line, strips surrounding +quotes, `...` markers, and terminal punctuation, and treats +`none` or `` as “no title yet.” A result longer than 80 characters or +12 words is rejected rather than truncated. Empty, deferred, or rejected output +leaves the session unnamed, so a later eligible title attempt can name it. + ## Full provider-facing replacement (SDK only) `CreateAgentSessionOptions.systemPrompt` is a different, lower-level API. A string or array replaces the fully rendered default blocks; a callback receives the rendered block array and returns its replacement. This can omit all generated context and safety blocks. diff --git a/docs/task-agent-discovery.md b/docs/task-agent-discovery.md index d4b37e3ee..77fa54e3d 100644 --- a/docs/task-agent-discovery.md +++ b/docs/task-agent-discovery.md @@ -40,10 +40,10 @@ Parsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers. - `output` is passed through as opaque schema data - `read-summarize: false` (normalized to `readSummarize`) forces the subagent's `read` tool to return verbatim file content instead of structural summaries — `runSubprocess` applies it as a `read.summarize.enabled: false` override on the subagent's isolated settings (`src/task/executor.ts`). `scout` and `librarian` ship with it disabled. Defaults to enabled when the field is absent. - `model` accepts one selector, CSV, or an array. Entries are tried in order after role aliases are expanded. -- `thinking-level` / `thinking` selects the agent's configured effort; when `task.enableEffort` (default `false`) exposes it, a task item's coarse `effort` (`lo`, `med`, `hi`) takes precedence at launch +- `thinking-level` / `thinking` selects the agent's configured effort. When `task.enableEffort` (default `false`) exposes it, a task item's coarse `effort` (`lo`, `med`, `hi`) takes precedence at launch. OMP maps that hint to the selected model's lowest, middle, or highest supported effort, then clamps it to `task.maxEffort` (default `max`). The ceiling is carried across retry-fallback model switches. If the selected model has no supported effort at or below the ceiling, the spawn fails; models without a controllable effort surface instead fall back to their normal selector. - `blocking: true` makes the parent wait for that agent even when async task execution is enabled - `autoloadSkills` names skills from the parent session to inject before the first child prompt; unknown names are ignored -- `prewalk: true` starts the subagent on its resolved model and hands off to the default prewalk target (the `smol` role) at its first edit/write, exactly like the session-level `--prewalk`; a string value (e.g. `prewalk: "@smol"` or `prewalk: "openai/gpt-5-mini"`) picks a custom target. The `task.agentPrewalk` settings record (agent name → `"on"` / `"off"` / pattern, toggled per agent from `/agents` with `P`) overrides the frontmatter. Resolution happens in `runSubprocess` (`src/task/executor.ts`); an unresolvable target or a target equal to the starting model skips the hand-off instead of failing the spawn. +- `prewalk: true` starts the subagent on its resolved model and hands off to the default prewalk target (the `smol` role) at its first edit/write, exactly like the session-level `--prewalk`; a string value (e.g. `prewalk: "@smol"` or `prewalk: "openai/gpt-5-mini"`) picks a custom target. The `task.agentPrewalk` settings record (agent name → `"on"` / `"off"` / pattern, toggled per agent from `/agents` with `P`) overrides the frontmatter. Resolution happens in `runSubprocess` (`src/task/executor.ts`). An unavailable target is skipped instead of failing the spawn. A resolved target is skipped only when both its model identity and its effective thinking mode/level match the starting selection after model clamping; a same-model effort downgrade is a real hand-off and still arms and switches at the first edit/write. ## Role-backed custom agents diff --git a/docs/toolconv/anthropic.md b/docs/toolconv/anthropic.md index 2178ad926..dca1b67b2 100644 --- a/docs/toolconv/anthropic.md +++ b/docs/toolconv/anthropic.md @@ -116,6 +116,20 @@ Tools are passed in the top-level `tools` array. Each user-defined (client) tool Anthropic-schema client tools (`bash`, `text_editor`, `computer`, `memory`) and server tools (`web_search`, `web_fetch`, `code_execution`, `tool_search`) instead carry a versioned `type`, e.g. `{"type": "web_search_20250305", "name": "web_search"}`. +### OMP native-adapter schema normalization + +The native Anthropic provider does not forward a pi tool's JSON Schema unchanged. Before placing it in `input_schema`, OMP keeps these keywords: + +- on every node: `$ref`, `$defs`, `$schema`, `definitions`, `type`, `enum`, `const`, `description`, `title`, `default`, and `nullable`; +- nested `anyOf` and `allOf` (root combinators are not retained, and `oneOf` is not retained at any depth); +- on objects: `properties`, `required`, and `additionalProperties`; +- on arrays: `items`, `prefixItems`, and `minItems` only when it is `0` or `1`; +- on strings: `format` only for `date-time`, `time`, `date`, `duration`, `email`, `hostname`, `uri`, `ipv4`, `ipv6`, or `uuid`. + +Other constraints—including `pattern`, string-length limits, numeric ranges, `maxItems`, unsupported formats, and unsupported combinators—are appended to that node's `description`. They remain model-visible guidance but are no longer machine-enforced schema keywords. Object nodes default to `additionalProperties: false`; an explicit `true` or schema-valued `additionalProperties` remains open (an empty schema normalizes to `true`). + +OMP sends `strict: true` only for eligible built-in tools (`bash`, `python`, `edit`, and `find`) when neither `PI_NO_STRICT` nor provider compatibility/runtime fallback has disabled strict tools, the tool has not opted out, the raw schema avoids `oneOf`, `allOf`, `$ref`, `patternProperties`, and `propertyNames`, and every object is closed. Selection is capped at 20 strict tools per request and shares budgets of 24 optional properties and 16 union uses: after the optional budget is exhausted, another optional property must be converted to required-and-nullable using union budget or that tool remains non-strict. Other tools use the normalized non-strict schema. OMP sends `eager_input_streaming: true` only when the model compatibility data and effective endpoint support it: first-party Anthropic endpoints qualify, as do custom endpoints explicitly configured for that capability; a canonical model rerouted to an unqualified non-Anthropic endpoint does not. + `tool_choice` controls invocation (four options): - `{"type":"auto"}` — model decides (default when `tools` present). - `{"type":"any"}` — must call some tool. @@ -196,6 +210,8 @@ OMP operates on the underlying prompt-driven XML rather than Messages API conten The scanner mints call ids because this XML has none. It scans streamed text statefully, emits `toolArgDelta` events while each parameter body arrives, and publishes the coerced argument object with `toolEnd` after `</invoke>`. A parameter value is capped at 1,000,000 JavaScript string code units; overflow gains an explicit truncation suffix. JSON-like values are parsed with repair, while schema-declared strings stay strings. With `parseThinking: true`, `<thinking>`, `<think>`, and `<scratchpad>` (prefixed or unprefixed) become thinking events; otherwise those tags remain visible text. +`</invoke>` gates `toolEnd`, but it does not gate creation of the canonical call. Once an opening `<invoke name="…">` has emitted `toolStart`, EOF only resets scanner-local state. On a normally stopped stream, OMP retains that call, changes the turn to `toolUse`, and may dispatch it even though no `toolEnd` arrived. Any `toolArgDelta` text already accumulated survives in the call (without the close-time coercion); a call with no accumulated parameter text runs with `{}`. A `length` stop remains non-runnable. + --- ## Multiple / parallel tool calls diff --git a/docs/toolconv/deepseek.md b/docs/toolconv/deepseek.md index 5b5e075b1..42387b46c 100644 --- a/docs/toolconv/deepseek.md +++ b/docs/toolconv/deepseek.md @@ -384,13 +384,17 @@ The current scanner accepts all three forms described above: - fullwidth or ASCII DSML `invoke` / `parameter` blocks. For V3.1 and legacy calls, omp emits `toolStart` after the header is complete -but buffers arguments until `</|tool▁call▁end|>`; it then uses the shared -repairing JSON parser. A missing/invalid argument object becomes `{}`, and an -unfinished call is discarded on flush. DSML is genuinely incremental: -`toolArgDelta` events are emitted while each parameter body arrives. A DSML -parameter is a raw string unless `string="false"`; the latter is -repairing-JSON-decoded and falls back to the raw text if decoding fails. Call -IDs for id-less DeepSeek forms are synthesized as `ptc_…`. +but buffers arguments until `<|tool▁call▁end|>`; it then uses the shared +repairing JSON parser. A missing/invalid completed argument object becomes +`{}`. Flush emits no `toolEnd` for an unfinished call and only clears the +scanner's private state. Once `toolStart` has been projected, however, the +canonical call remains and a normally stopped turn may dispatch it: unfinished +V3.1/legacy calls retain `{}`, while DSML calls retain any argument text already +published through `toolArgDelta`. DSML is genuinely incremental: parameter +body text is streamed as those deltas. A DSML parameter is a raw string unless +`string="false"`; the latter is repairing-JSON-decoded at a completed close and +falls back to the raw text if decoding fails. Call IDs for id-less DeepSeek +forms are synthesized as `ptc_…`. The scanner also removes leaked DeepSeek chat-template control tokens from visible text and, by default, maps `<think>…</think>` to thinking events. Its diff --git a/docs/toolconv/gemini.md b/docs/toolconv/gemini.md index 5eb7691fe..fe0e56bc8 100644 --- a/docs/toolconv/gemini.md +++ b/docs/toolconv/gemini.md @@ -125,7 +125,7 @@ It's currently 11.4°C in London. ## OpenAI-compatible / native API mapping -- Hosted Gemini's native API normally returns a structured `functionCall` part (`{name, args}`); for Gemini 3 each carries an `id` that must be echoed in the matching `functionResponse`, plus a `thoughtSignature` that must be preserved. The Pythonic text form is what you get when the structured path *fails* (`finish_reason = MALFORMED_FUNCTION_CALL`) or when tool use is driven purely by prompt (Gemma, or Gemini via the code-execution `executableCode` part). +- Hosted Gemini's native API normally returns a structured `functionCall` part (`{name, args}`). On direct Gemini Generative AI requests, Gemini 3 calls carry an `id` that OMP echoes in the matching `functionResponse`; their `thoughtSignature` must also be preserved. OMP's Vertex adapter is the exception: Vertex GenerateContent rejects function-part IDs, so OMP omits `id` from both `functionCall` and `functionResponse`, retains the originating function name, and relies on function name/order for matching. Thought signatures are still preserved. - When parsed out of an OpenAI-compatible shim, each recovered call becomes `tool_calls[i] = {id (server-minted), type:"function", function:{name, arguments:<JSON string>}}` — the Python kwargs are re-serialized to a JSON string at that boundary. - Feed results back as the deployment's tool/`functionResponse` turn (hosted) or a `tool_outputs` block in the next user turn (prompt-driven). @@ -139,6 +139,7 @@ It's currently 11.4°C in London. - **OMP streaming behavior.** The scanner buffers the entire `tool_code` body and emits tool events only after the closing fence; it does not stream partial arguments. An unterminated block is discarded on flush rather than exposed as text. Positional arguments and malformed keyword segments are skipped. In addition to ordinary quoted strings, the literal decoder accepts Python raw/byte/unicode prefixes, triple quotes, octal escapes, and `\x`/`\u`/`\U` escapes. - **Transcript rendering.** OMP wraps the transcript in `<bos>` and Gemma-style `<start_of_turn>user|model` turns. `developer` text is prepended to the next user turn (or emitted as its own user turn if no user message follows); consecutive tool results become one user turn containing their separate `tool_outputs` blocks. - **Variant divergence.** Gemma **4** abandoned this Pythonic form for a token-delimited brace syntax (`<|tool_call>call:NAME{…}<tool_call|>`) — a different convention documented in `gemma.md`. This spec covers hosted Gemini and Gemma 3. +- **Gemma 3 automatic-selection caveat.** OMP's current family affinity maps every recognized Gemma version—including Gemma 3—to the `gemma` dialect. Therefore, when a Gemma 3 model is marked `supportsTools: false` and falls back from native tools, `tools.format=auto` selects the incompatible Gemma 4 grammar. Set `tools.format=gemini` explicitly for the Pythonic Gemma 3 convention documented here. ## Sources diff --git a/docs/toolconv/gemma.md b/docs/toolconv/gemma.md index e1a9fa94c..6c13e9d0d 100644 --- a/docs/toolconv/gemma.md +++ b/docs/toolconv/gemma.md @@ -11,11 +11,11 @@ Gemma 4 wraps each structural element in a paired token. Note the **asymmetric p | Open | Close | Purpose | |---|---|---| | `<bos>` | — | Beginning of sequence | -| `<|turn>` | `<turn|>` | One conversation turn; the role name is the first line of the body | -| `<|tool_call>` | `<tool_call|>` | One tool **call** emitted by the model | -| `<|tool_response>` | `<tool_response|>` | One tool **result** fed back to the model | -| `<|channel>` | `<channel|>` | Reasoning channel; `<|channel>thought` opens the model's chain-of-thought (closed by `<channel|>`) before the visible reply | -| `<|"|>` | `<|"|>` | String-literal delimiter (same token on both ends) | +| `<\|turn>` | `<turn\|>` | One conversation turn; the role name is the first line of the body | +| `<\|tool_call>` | `<tool_call\|>` | One tool **call** emitted by the model | +| `<\|tool_response>` | `<tool_response\|>` | One tool **result** fed back to the model | +| `<\|channel>` | `<channel\|>` | Reasoning channel; `<\|channel>thought` opens the model's chain-of-thought (closed by `<channel\|>`) before the visible reply | +| `<\|"\|>` | `<\|"\|>` | String-literal delimiter (same token on both ends) | | `<eos>` | — | End of sequence | Because the string delimiter is a token (`<|"|>`), values may contain raw ASCII quotes and commas without escaping — only a literal `<|"|>` token sequence cannot appear inside a string. @@ -28,7 +28,7 @@ Each turn is `<|turn>{role}\n{body}<turn|>`, and turns are concatenated with no ## Tool definitions -The `gemma` dialect does not put tool schemas on the wire. Tools are advertised in the system prompt by `renderInbandToolPrompt` (`packages/ai/src/dialect/catalog.ts`): an OpenAI-style JSON catalog — one object per line inside a `<tools></tools>` block — followed by the format guide (`packages/ai/src/dialect/gemma.md`): +The owned `gemma` prompt **does** carry each tool's normalized wire schema. `renderInbandToolPrompt` serializes one compact OpenAI-style object per line inside `<tools></tools>`, followed by the Gemma format guide: ```text <tools> @@ -36,7 +36,7 @@ The `gemma` dialect does not put tool schemas on the wire. Tools are advertised </tools> ``` -The verbose system-prompt inventory and `/dump` additionally render each tool as a `# Tool: <name>` section — description, a TypeScript-style parameter signature, and native `<|tool_call>` examples — via `renderToolInventory` (`packages/ai/src/dialect/inventory.ts`). +`renderToolInventory` is a separate verbose inventory used by the system prompt and `/dump`. It emits one `## functions` TypeScript `namespace functions { … }` block. Tool descriptions are `//` comments above `type NAME = (_: PARAMS);` declarations; configured examples appear as JSDoc-style `// @example` entries whose calls use Python keyword-argument syntax. It does not emit per-tool Markdown sections or native Gemma `<|tool_call>` examples. ## Tool-call format @@ -50,12 +50,12 @@ Value grammar inside `{…}`: | Value kind | Encoding | Example | |---|---|---| -| string | `<|"|>text<|"|>` | `location:<|"|>London<|"|>` | +| string | `<\|"\|>text<\|"\|>` | `location:<\|"\|>London<\|"\|>` | | int / float | bare | `count:42` | | bool | bare | `flag:true` | | null | bare | `unit:null` | -| list | `[v,v,…]` | `tags:[<|"|>a<|"|>,<|"|>b<|"|>]` | -| nested object | `{k:v,…}` | `config:{theme:<|"|>dark<|"|>}` | +| list | `[v,v,…]` | `tags:[<\|"\|>a<\|"\|>,<\|"\|>b<\|"\|>]` | +| nested object | `{k:v,…}` | `config:{theme:<\|"\|>dark<\|"\|>}` | The OMP parser is the streaming `GemmaInbandScanner` (`packages/ai/src/dialect/gemma.ts`), not a flat regex. For each `<|tool_call>` block it: @@ -97,8 +97,9 @@ The current weather in Tokyo is 15 degrees Celsius and sunny.<turn|> - **Asymmetric pipes.** The closer is `<tool_call|>`, not `</tool_call>` or `<|tool_call>`. Matching the wrong pipe side will never close the block. - **One call per block.** Unlike a JSON `tool_calls[]` array, parallelism is "more blocks", not "more entries in one block". - **Bare scalars.** A value not wrapped in `<|"|>` is `true`/`false` → bool, `null`/`none` → null, numeric → number, otherwise a bare string (e.g. an unquoted enum or type name like `STRING`). -- **Tool-call ids are synthesized.** The format carries no id; OMP mints one when the scanner opens each call block and correlates rendered responses by the surrounding message order/name. +- **Tool-call ids are synthesized.** The format carries no id; after receiving a complete closed block, OMP parses it and emits adjacent `toolStart`/`toolEnd` events with a newly minted id. Rendered responses are correlated by surrounding message order/name. - **Not Gemma 3 / hosted Gemini.** Those use the Pythonic `tool_code` / `default_api` form in `gemini.md`. Gemma 4 replaced it with this token syntax; the two are not interchangeable. +- **Gemma 3 automatic-selection caveat.** OMP's current family affinity maps Gemma 3 and Gemma 4 model IDs to `gemma`. If a Gemma 3 model is marked `supportsTools: false`, `tools.format=auto` therefore chooses this Gemma 4 grammar even though Gemma 3 requires the Pythonic convention in `gemini.md`; set `tools.format=gemini` explicitly. ## Sources diff --git a/docs/toolconv/glm-4.5.md b/docs/toolconv/glm-4.5.md index b8c05b36a..e79c4f40d 100644 --- a/docs/toolconv/glm-4.5.md +++ b/docs/toolconv/glm-4.5.md @@ -307,12 +307,17 @@ instead. The scanner synthesizes `ptc_…` ids, emits `toolStart` once the name delimiter arrives, and streams each argument body as keyed `toolArgDelta` events. -String-only schema properties stay verbatim; every other property is parsed -with strict `JSON.parse` after trimming and falls back to the original raw text -on failure. An unfinished key/value drops the open call on flush. The scanner -also heals narrowly recognizable model mistakes: `</arg_key>` used in place -of `</arg_value>`, a stray wrong closer before the real closer, and a missing -value closer immediately before the next argument or call close. +String-only schema properties stay verbatim; every other completed property is +parsed with strict `JSON.parse` after trimming and falls back to the original +raw text on failure. On flush, an unfinished key/value drops only the scanner's +private call state. If `toolStart` was already emitted, OMP retains the +canonical call and a normal stop may dispatch it; previously accumulated +arguments—including partial value text published through `toolArgDelta`—remain +on that call. Input that never yields a valid name emits no `toolStart` and +therefore leaves no call. The scanner also heals narrowly recognizable model +mistakes: `</arg_key>` used in place of `</arg_value>`, a stray wrong closer +before the real closer, and a missing value closer immediately before the next +argument or call close. Thinking parsing is enabled by default and excludes `<think>…</think>` from visible text. If `<tool_response>` appears in assistant output, the scanner diff --git a/docs/toolconv/harmony.md b/docs/toolconv/harmony.md index 650399ad5..2dedab7b4 100644 --- a/docs/toolconv/harmony.md +++ b/docs/toolconv/harmony.md @@ -132,6 +132,8 @@ OMP emits the first form above: no `<|constrain|>` marker, recipient in the chan Arguments are accumulated until `<|call|>`, `<|end|>`, or `<|return|>` and parsed with JSON repair. Empty arguments, or input that still cannot be parsed after repair, become `{}` rather than a scanner error. The scanner emits `toolStart` when the header completes and `toolEnd` only at the message terminator; `analysis` body chunks stream as thinking deltas, while ordinary assistant `commentary`/`final` bodies stream as text. Non-assistant messages, including tool-result envelopes, are skipped by this output scanner. +An important owned-scanner edge case differs from canonical Harmony. After a recipient-bearing header reaches `<\|message\|>`, OMP has already emitted `toolStart`. If the ordinary streaming path drains the body bytes and the stream then ends without `<\|call\|>`, `<\|end\|>`, or `<\|return\|>`, `flush()` emits no `toolEnd` and does not retract the start. The Harmony scanner emits no argument deltas, so the retained canonical call still has `{}` even if unterminated body text was seen. On a normal stop, OMP changes the turn to `toolUse` and may dispatch that empty call. This is permissive and unsafe recovery behavior, not a valid Harmony terminator rule. + ## Multiple / parallel tool calls Harmony has no special "parallel" wrapper. Multiple calls are just multiple consecutive messages. The model may first emit an optional **preamble** — a *user-visible* assistant message on the `commentary` channel (unlike `analysis`, this is meant to be shown) — then one tool-call message per function. Each individual call still ends with its own `<|call|>` stop token, so a host that stops on `<|call|>` collects calls one at a time, executes, feeds the result back, and resumes: @@ -207,7 +209,8 @@ When a server (vLLM/SGLang/Ollama) bridges Harmony to Chat Completions JSON: - **Tool result messages** (`{"role":"tool","tool_call_id":...,"content":...}`) are rendered into `<|start|>{toolname} to=assistant<|channel|>commentary<|message|>{content}<|end|>`. The server maps `tool_call_id` → the original function name to build the `{toolname}` author. - **Reasoning**: `analysis`-channel text is surfaced as `reasoning_content` (vLLM/SGLang) or as a `reasoning`/`thinking` field, and is generally not echoed back on subsequent requests. `final`-channel text is the normal `message.content`. `commentary` preambles, if surfaced, also map to assistant content. - **OMP transcript rendering:** `developer`, `user`, and other non-assistant roles map directly to Harmony envelopes. Assistant messages emit, in order, a complete `analysis` message for thinking, a complete `final` message for visible text, then one `commentary` call message per tool call. Thus visible text accompanying a tool call is rendered as `final`, not as a commentary preamble. Tool-result runs become consecutive canonical tool-author envelopes. -- **`tools` / `tool_choice`** request fields are compiled by the chat template into the developer-message `namespace functions { ... }` block; the system message gains the commentary-routing line. +- **Native server/chat-template compilation:** on the native vLLM/SGLang path, request `tools` / `tool_choice` are compiled by the server's chat template into the developer-message `namespace functions { ... }` block; the system message gains the commentary-routing line. +- **OMP owned-dialect advertisement:** when OMP's `harmony` dialect is selected, OMP removes native provider tools and appends its generic compact `<tools>` JSON catalog plus the Harmony format guide to the system prompt. This path does not use the canonical developer-message namespace as its tool advertisement. ## Parsing notes & gotchas diff --git a/docs/toolconv/kimi-k2.md b/docs/toolconv/kimi-k2.md index 5e298ce40..280b06a03 100644 --- a/docs/toolconv/kimi-k2.md +++ b/docs/toolconv/kimi-k2.md @@ -196,14 +196,16 @@ tool-result messages are collapsed into one synthetic user message containing that text. The scanner recognizes only calls inside a section. Once the argument marker -arrives it preserves the raw header as the call id and derives the name from -the last dot-separated segment before the first colon. It emits `toolStart` at -that point, buffers the argument body until `<|tool_call_end|>`, then applies -the shared repairing JSON parser and emits `toolEnd`; it does **not** emit -incremental argument deltas. Invalid/non-object arguments normalize to `{}`, -and an unfinished call is discarded on flush. Section markers are suppressed -from visible text, while an isolated call marker outside a section remains -ordinary text. +arrives it preserves the raw header as the call id, derives the name from the +last dot-separated segment before the first colon, and emits `toolStart`. It +buffers the argument body until `<|tool_call_end|>`, then applies the shared +repairing JSON parser and emits `toolEnd`; it does **not** emit incremental +argument deltas. Invalid/non-object completed arguments normalize to `{}`. +If EOF arrives after `toolStart` but before the close marker, no `toolEnd` is +emitted, yet the canonical `{}` call remains and may be dispatched on a normal +stop. Only incomplete input that never reaches the argument marker is +discarded without creating a call. Section markers are suppressed from visible +text, while an isolated call marker outside a section remains ordinary text. Thinking parsing is enabled by default and maps `<think>…</think>` to thinking events. `parseThinking: false` leaves those tags and their contents in visible diff --git a/docs/toolconv/minimax.md b/docs/toolconv/minimax.md new file mode 100644 index 000000000..fac686aad --- /dev/null +++ b/docs/toolconv/minimax.md @@ -0,0 +1,211 @@ +# MiniMax owned tool-calling format (`<minimax:tool_call>`) + +OMP's `minimax` dialect is the prompt-driven, in-band tool protocol for MiniMax-family models. Calls are ordinary assistant text: one `<minimax:tool_call>` envelope contains one or more `<invoke>` elements. OMP executes the parsed calls and returns a `<function_results>` block in the next user turn. The format carries no tool-call ids, so calls and results are correlated by order. + +This reference describes OMP's implemented converter, not MiniMax's provider-native structured tool API. It is verified against `packages/ai/src/dialect/minimax.ts`, the shared XML scanner in `packages/ai/src/dialect/anthropic.ts`, prompt assembly in `packages/ai/src/dialect/catalog.ts`, and the streaming projection in `packages/ai/src/dialect/owned-stream.ts`. + +## Selection and request conversion + +Set the format explicitly in `~/.omp/agent/config.yml` or a project/overlay config: + +```yaml +tools: + format: minimax +``` + +`tools.format: minimax` forces this owned dialect for the session. In `auto` mode, OMP keeps provider-native tool calling unless the selected model explicitly has `supportsTools: false`; for a MiniMax-family model id, that fallback resolves to `minimax`. See [`tools.format`](../settings.md#tools-and-approvals). + +When an owned dialect is active, OMP: + +1. removes the native structured `tools` field from the provider request; +2. appends an in-band tool catalog and the MiniMax format guide to the system prompt; +3. rewrites prior structured assistant calls and tool-result messages into this text protocol; and +4. scans the model's text stream back into structured tool-call events. + +## Tool definitions and prompt injection + +The injected prompt begins with `# Tools`, says calls are text rather than native provider tool messages, and lists the available functions inside `<tools></tools>`. Each line is a compact OpenAI-style function object containing the normalized wire schema: + +```text +<tools> +{"type":"function","function":{"name":"read","description":"Read a file","parameters":{"type":"object","properties":{"path":{"type":"string"},"count":{"type":"number"}},"required":["path"]}}} +</tools> +``` + +The catalog is followed by the MiniMax-specific guide from `packages/ai/src/dialect/minimax.md`. Its contract requires a listed function name, literal string/scalar bodies, JSON lists/objects, one envelope for a batch, and no model-authored result blocks. + +## Tool-call envelope + +A single call is: + +```text +<minimax:tool_call> +<invoke name="read"><parameter name="path">src/main.ts</parameter><parameter name="count">40</parameter></invoke> +</minimax:tool_call> +``` + +Exact structure: + +| Element | Meaning | +| --- | --- | +| `<minimax:tool_call>…</minimax:tool_call>` | Required model-output envelope in the prompt contract. | +| `<invoke name="TOOL">…</invoke>` | One call. `name` must be a listed tool. | +| `<parameter name="ARG">VALUE</parameter>` | One named argument. Arguments occur directly inside the invoke. | + +The renderer XML-escapes tool and argument names in attributes. Parameter bodies are deliberately **not** XML-escaped: this protocol is delimiter-matched rather than parsed as XML. For example, a string body is `a & b < c`, not `a & b < c`. A literal `</parameter>` is the one reserved sequence because it closes that argument. + +The scanner is more tolerant than the prompt contract. It accepts the namespaced wrapper above, an unprefixed `<tool_call>` wrapper, or a bare `<invoke>` outside a wrapper. Models should still emit the canonical `<minimax:tool_call>` form so behavior does not depend on recovery paths. + +## Argument encoding and coercion + +Encoding uses the selected tool's schema: + +| Declared/value kind | Rendered parameter body | Parsed value | +| --- | --- | --- | +| Schema-declared string whose runtime value is a string | Verbatim text, including leading/trailing spaces and newlines | Verbatim string | +| Number, boolean, `null`, array, or object | JSON | Parsed JSON value | +| Value without a matching string schema | JSON, including quotes around a string | Parsed JSON when valid | + +Example: + +```text +<invoke name="write"><parameter name="path">notes/a & b.txt</parameter><parameter name="options">{"append":false,"tags":["x","y"]}</parameter></invoke> +``` + +The scanner resolves string arguments from the supplied tool schemas. A parameter attribute can override that decision: + +- `string="true"` (and any value except `false`, `0`, or `no`) forces verbatim string handling. +- `string="false"`, `string="0"`, or `string="no"` forces JSON parsing even for a schema-declared string. + +For a non-string parameter, surrounding whitespace is trimmed only for the JSON parse. OMP uses its repair-capable JSON parser; if parsing still fails, the original body is retained as a string rather than dropping the argument. Empty bodies also remain empty strings. A parameter with no usable `name` is ignored. + +## Multiple and parallel calls + +Parallel calls are sibling `<invoke>` elements inside one envelope, in emitted order: + +```text +<minimax:tool_call> +<invoke name="read"><parameter name="path">src/a.ts</parameter></invoke> +<invoke name="read"><parameter name="path">src/b.ts</parameter></invoke> +</minimax:tool_call> +``` + +The scanner mints an internal id for each invoke because the wire format has no id. OMP can dispatch the resulting calls as a batch. Tool results must be returned in the same order; the result protocol has no call id with which to repair reordering. + +## Tool-result envelope + +OMP batches consecutive tool results into one `<function_results>` block. Success and failure use different records: + +```text +<function_results> +<result> +<tool_name>read</tool_name> +<stdout>file contents</stdout> +</result> +<error> +<tool_name>read</tool_name> +<stderr>ENOENT: file not found</stderr> +</error> +</function_results> +``` + +For every result: + +- success is `<result>` with `<stdout>`; +- `isError: true` is `<error>` with `<stderr>`; +- `<tool_name>` is XML-text escaped; +- stdout/stderr is inserted verbatim; and +- there is no call id, so the model reads records in call order. + +OMP places this text in a synthesized `user` message. Text blocks from one tool result are concatenated; image result blocks remain image blocks after the rendered text. The model must never emit `<function_results>` or `<tool_response>` itself. + +## Thinking and visible text + +OMP renders a preserved reasoning block as: + +```text +<thinking> +reasoning text +</thinking> +``` + +In the normal owned-tool stream, thinking parsing is enabled. The MiniMax scanner recognizes `<thinking>`, `<think>`, and `<scratchpad>` (including the supported prefixed forms), emits separate thinking events, and keeps the content out of visible assistant text. If `parseThinking` is disabled for a direct scanner consumer, those tags remain visible text. An unterminated thinking block is closed logically on stream flush and its accumulated content is retained. + +Visible prose may precede the tool envelope. Text outside calls remains assistant text; non-call text inside the wrapper is discarded by the scanner. + +## Streaming, malformed output, and recovery + +The scanner is incremental and chunk-boundary safe: opening/closing tags and parameter bodies may arrive in separate provider deltas. Its observable lifecycle is: + +1. a non-empty `<invoke name="…">` emits `toolStart` immediately; +2. each named parameter body emits keyed `toolArgDelta` events as text chunks arrive; and +3. the matching `</invoke>` performs final coercion and emits `toolEnd` with the complete arguments and exact raw invoke block. + +Important failure behavior: + +- **Missing call name:** no tool lifecycle is emitted for that invoke. +- **Missing parameter name:** that parameter is ignored. +- **Malformed JSON:** falls back to the original parameter text. +- **Very large parameter:** input is capped at 1,000,000 JavaScript string code units; overflow is replaced by the accepted prefix plus an explicit truncation marker. +- **Incomplete invoke:** flush resets scanner-local call state and emits no `toolEnd`. However, OMP's stream projector has already materialized a call from `toolStart`; on a normally stopped response it retains that partial call, marks the turn as tool use, and may dispatch it. Already streamed argument text remains uncoerced, and a call with no argument text has `{}`. A provider `length` stop remains `length` rather than becoming runnable tool use. +- **Incomplete wrapper after complete invokes:** already closed invokes remain valid; the wrapper close is not required to emit their `toolEnd` events. +- **Incomplete thinking:** retained as thinking and logically ended at flush. + +OMP also guards against a model fabricating tool output after its call. For this dialect, the first `<function_results>` or `<tool_response>` boundary stops projection. With the default `tools.abortOnFabricatedResult: true`, generation is aborted immediately; when disabled, OMP drains the provider stream but discards the fabricated continuation. + +## End-to-end example + +Injected tool definition (abbreviated to the relevant catalog line): + +```text +<tools> +{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"},"units":{"type":"string"}},"required":["city"]}}} +</tools> +``` + +Assistant call: + +```text +I'll check both cities. +<minimax:tool_call> +<invoke name="get_weather"><parameter name="city">Tokyo</parameter><parameter name="units">celsius</parameter></invoke> +<invoke name="get_weather"><parameter name="city">Oslo</parameter><parameter name="units">celsius</parameter></invoke> +</minimax:tool_call> +``` + +Next user turn produced by OMP: + +```text +<function_results> +<result> +<tool_name>get_weather</tool_name> +<stdout>{"temperature":28,"condition":"clear"}</stdout> +</result> +<result> +<tool_name>get_weather</tool_name> +<stdout>{"temperature":14,"condition":"rain"}</stdout> +</result> +</function_results> +``` + +The assistant can then answer normally or emit another complete MiniMax call envelope. + +## Parsing notes and gotchas + +- **Not real XML.** Do not entity-escape parameter bodies or run them through an XML DOM parser; matching is based on protocol delimiters. +- **One envelope, many invokes.** Parallelism is sibling calls inside `<minimax:tool_call>`, not JSON `tool_calls` and not one envelope per required batch. +- **Schema determines strings.** Without the tool schema, even a JavaScript string renderer value is JSON-quoted; supply tool definitions to renderer/scanner APIs for round trips. +- **No ids on the wire.** OMP-generated ids are internal. Preserve call/result order. +- **Errors are first-class records.** Use `<error>/<stderr>`, not a successful `<result>` containing an out-of-band error flag. +- **Canonical wrapper vs accepted recovery syntax.** The parser accepts bare invokes and `<tool_call>`, but the injected contract requires `<minimax:tool_call>`. +- **Complete the invoke before stopping.** A natural-language promise to call a tool is not a call; the closing `</invoke>` is what finalizes coercion and the normal lifecycle. + +## Sources + +- `packages/ai/src/dialect/minimax.md` — injected MiniMax format guide. +- `packages/ai/src/dialect/minimax.ts` — call, result, thinking, and transcript renderers plus scanner configuration. +- `packages/ai/src/dialect/anthropic.ts` — shared incremental invoke/parameter scanner and coercion behavior. +- `packages/ai/src/dialect/catalog.ts` and `prompt-template.md` — tool catalog and system-prompt injection. +- `packages/ai/src/dialect/history.ts` and `owned-stream.ts` — history conversion, streamed projection, incomplete-call behavior, and fabricated-result boundary. +- `packages/catalog/src/identity/dialect.ts` and `packages/coding-agent/src/sdk.ts` — MiniMax family affinity and `tools.format` resolution. +- `packages/ai/test/inband-tools.test.ts` — prompt rendering, call round trips, chunked argument deltas, raw blocks, MiniMax wrapper recovery, and result rendering. diff --git a/docs/toolconv/pi-native.md b/docs/toolconv/pi-native.md index 5b570efe4..c924e7b35 100644 --- a/docs/toolconv/pi-native.md +++ b/docs/toolconv/pi-native.md @@ -124,9 +124,13 @@ idle watchdogs use request options when supplied, otherwise the standard The initial `start` event is not considered progress for the idle watchdog. If the SSE connection closes without a terminal event, the client synthesizes -a terminal assistant boundary so `.result()` cannot hang: caller cancellation -becomes an `error` event with `stopReason: "aborted"`; otherwise it becomes an -empty `done` event with `stopReason: "stop"`. +a terminal assistant boundary so `.result()` cannot hang. Caller cancellation +emits `{type:"error", reason:"aborted", error: syntheticAssistant}`; the nested +`AssistantMessage` has `stopReason:"aborted"` and +`errorMessage:"stream closed without terminal event"`. Any other clean close +emits `{type:"done", reason:"stop", message: syntheticAssistant}`, whose nested +message has `stopReason:"stop"`. Thus `reason` is the top-level event field; +`stopReason` exists only on the nested `AssistantMessage`. The client consumes streaming responses only. The server endpoint also supports `stream: false`, returning: @@ -139,7 +143,7 @@ with the full canonical `AssistantMessage` in `message`. ## Errors -Pre-stream HTTP failures use: +Provider/handler failures that reach the pi-native route use: ```json { "error": { "type": "rate_limit_error", "message": "..." } } @@ -147,10 +151,14 @@ Pre-stream HTTP failures use: with the appropriate HTTP status, `Content-Type: application/json`, and `Cache-Control: no-store`. The client converts this shape into -`AuthGatewayError`, preserving status, response headers, and `type`. A -nonconforming error body falls back to -`auth-gateway STATUS: BODY_OR_STATUS_TEXT`. A successful response with no body -is also an `AuthGatewayError`. +`AuthGatewayError`, preserving status, response headers, and `type`. + +Bearer authentication runs before the route handler. A missing or invalid +gateway bearer is rejected as `{"error":"unauthorized"}` instead of the +structured provider envelope; the client therefore uses its generic +`auth-gateway STATUS: BODY_OR_STATUS_TEXT` fallback and has no provider error +`type` to preserve. Other nonconforming error bodies use the same fallback. A +successful response with no body is also an `AuthGatewayError`. ## Source of truth diff --git a/docs/toolconv/qwen3.md b/docs/toolconv/qwen3.md index 188b8620e..518df5863 100644 --- a/docs/toolconv/qwen3.md +++ b/docs/toolconv/qwen3.md @@ -197,22 +197,27 @@ pi tool-call events. `hermes` remains a separate selectable dialect even though both emit the same basic JSON-in-`<tool_call>` convention. The catalog's current family-affinity helper maps every model id containing -`qwen` to `qwen3`, including Qwen3-Coder. That broad affinity does not change -the format distinction described below, so callers must explicitly select the -appropriate dialect for Coder endpoints. +`qwen` to `qwen3`, including Qwen3-Coder. For a Coder endpoint, set +`tools.format=native` (or the equivalent native-tool setting) and configure the +serving endpoint itself with its `qwen3_xml` parser. `qwen3_xml` is not an +OMP-owned dialect and therefore is not a valid `tools.format` value. The omp renderer always writes a nested `arguments` object and renders parallel calls newline-separated. Results become newline-delimited `<tool_response>` blocks inside the synthetic user history message. The -scanner mints an id (`ptc_…`), emits `toolStart` as soon as the leading JSON -contains a complete string `name`, and waits for `</tool_call>` before emitting -`toolEnd`; it does not stream argument deltas. At close it uses the shared +scanner mints an id (`ptc_…`) and emits `toolStart` as soon as the leading JSON +contains a complete string `name`. It waits for `</tool_call>` before emitting +`toolEnd` and does not stream argument deltas. At close it uses the shared repairing JSON parser. For compatibility it also accepts a stringified `arguments` value and parses it once more, although the owned renderer never -emits that shape. A malformed completed block, a non-object argument value, or -an unfinished block does not become visible fallback prose: malformed calls -are consumed, with a string parse failure/non-object normalized to `{}` or the -whole call omitted when its outer object/name cannot be recovered. +emits that shape. A completed string parse failure or non-object argument +normalizes to `{}`; a completed outer object whose name cannot be recovered is +consumed without creating a call. + +If EOF arrives after the name was recovered but before `</tool_call>`, no +`toolEnd` is emitted, but the canonical call created by `toolStart` survives +with empty arguments and may be dispatched on a normal stop. Malformed input +that never yields a name produces no call. Thinking parsing is enabled by default: `<think>…</think>` becomes thinking events and is excluded from visible text. Callers creating the scanner can set @@ -235,7 +240,7 @@ text. through vLLM's structured-outputs backend when using vLLM native tools, but owned mode sends no native provider tool definition and therefore cannot rely on that backend. -- **Version/scope:** this `hermes` template covers `Qwen3-*`, `Qwen2.5-*`, and `QwQ-32B`. It does **not** cover `Qwen3-Coder`, which uses a different XML scheme parsed by vLLM's `qwen3_xml` parser — a separate convention. +- **Version/scope:** this `hermes` template covers `Qwen3-*`, `Qwen2.5-*`, and `QwQ-32B`. It does **not** cover `Qwen3-Coder`, which uses a different XML scheme parsed by a serving engine's `qwen3_xml` parser. OMP has no `qwen3_xml` owned dialect; use `tools.format=native` and configure that parser at the endpoint. ## Sources diff --git a/docs/toolconv/xml.md b/docs/toolconv/xml.md new file mode 100644 index 000000000..24de99ba4 --- /dev/null +++ b/docs/toolconv/xml.md @@ -0,0 +1,241 @@ +# Generic XML owned tool-calling format (`<invoke>` / `<tool_response>`) + +OMP's `xml` dialect is a generic, prompt-driven in-band protocol. The model writes one `<invoke>` element per tool call directly in assistant text; OMP parses those calls and returns one ordered `<tool_response>` block per result in the next user turn. Neither side carries tool-call ids, and result blocks do not carry tool names, so ordering is the correlation mechanism. + +This reference describes the converter implemented by `packages/ai/src/dialect/xml.ts`. The ordinary `tools.format: xml` path uses the shared Anthropic-style invoke scanner. The exported scanner API can instead select DeepSeek's pipe-wrapped DSML tagset; that scanner-only option is documented separately below. + +## Selection and request conversion + +Select the dialect in `~/.omp/agent/config.yml`, project config, or an overlay: + +```yaml +tools: + format: xml +``` + +`tools.format: xml` forces the generic XML owned dialect for the session. `auto` does **not** choose generic XML as its unknown-family fallback: when a model has `supportsTools: false`, the resolver chooses the known model-family dialect or GLM if there is no specific affinity. Use `xml` explicitly when this grammar is required. See [`tools.format`](../settings.md#tools-and-approvals). + +When selected, OMP removes native structured tools from the provider request, appends the in-band tool catalog and XML guide to the system prompt, converts prior structured calls/results to text, and scans assistant text back into structured tool-call events. + +## Tool definitions and prompt injection + +OMP injects the shared `# Tools` prompt. Available functions appear inside `<tools></tools>` as one compact OpenAI-style function object per line, using each tool's normalized wire schema: + +```text +<tools> +{"type":"function","function":{"name":"read","description":"Read a file","parameters":{"type":"object","properties":{"path":{"type":"string"},"count":{"type":"number"}},"required":["path"]}}} +</tools> +``` + +The XML-specific guide from `packages/ai/src/dialect/xml.md` follows the catalog. It requires listed function names, literal string bodies, JSON non-string values, ordered results, and complete calls before the model stops. Calls are text, never native `tool_calls` JSON. + +## Canonical call format + +One call is one invoke: + +```text +<invoke name="read"><parameter name="path">src/main.ts</parameter><parameter name="count">40</parameter></invoke> +``` + +| Element | Meaning | +| --- | --- | +| `<invoke name="TOOL">…</invoke>` | One tool call. The prompt contract requires a listed tool name. | +| `<parameter name="ARG">VALUE</parameter>` | One named argument. | +| `<tool_calls>…</tool_calls>` | Optional model-emitted wrapper accepted by the guide/scanner; OMP's renderer does not add it. | + +`renderAssistantToolCalls` emits consecutive invokes separated by newlines, with no outer wrapper. The default scanner also accepts `<function_calls>` as a wrapper alias, `antml:`-prefixed variants of the Anthropic tags, and a bare invoke. Its accepted input is deliberately wider than the canonical renderer output. + +Tool and parameter names are XML-escaped when OMP renders attributes. Parameter bodies are not XML-escaped because the format is delimiter-matched, not parsed by an XML DOM. Write `a & b < c`, not `a & b < c`; only a literal `</parameter>` conflicts with the body's close delimiter. + +## Argument encoding and coercion + +The renderer uses the supplied tool schema to decide whether a value is a literal string: + +| Declared/value kind | Rendered body | Default scanner result | +| --- | --- | --- | +| Schema-declared string whose runtime value is a string | Verbatim, whitespace preserved | Verbatim string | +| Number, boolean, `null`, array, or object | JSON | Parsed JSON value | +| Runtime string not identified as a string argument | JSON string, including quotes | Parsed string | + +Example: + +```text +<invoke name="write"><parameter name="path">notes/a & b.txt</parameter><parameter name="options">{"append":false,"tags":["draft","xml"]}</parameter></invoke> +``` + +The default scanner accepts a `string` override on each parameter: + +- `string="true"` (or any value other than `false`, `0`, or `no`) forces the raw body to remain a string. +- `string="false"`, `string="0"`, or `string="no"` forces JSON parsing even when the schema declares a string. + +Non-string bodies are trimmed for parsing and passed through OMP's repair-capable JSON parser. If repair fails, the original body is retained as a string. Empty bodies remain empty strings. A parameter without a usable name is discarded. + +## Multiple and parallel calls + +OMP renders a batch as consecutive invokes: + +```text +<invoke name="read"><parameter name="path">src/a.ts</parameter></invoke> +<invoke name="read"><parameter name="path">src/b.ts</parameter></invoke> +``` + +The model may optionally wrap the batch: + +```text +<tool_calls> +<invoke name="read"><parameter name="path">src/a.ts</parameter></invoke> +<invoke name="read"><parameter name="path">src/b.ts</parameter></invoke> +</tool_calls> +``` + +The scanner mints one internal call id per invoke; there is no id in the XML. OMP can dispatch the calls as a batch. Results must preserve call order because `<tool_response>` has neither id nor name. + +## Tool-result format + +OMP returns each result in its own block: + +```text +<tool_response> +file contents +</tool_response> +<tool_response> +ENOENT: file not found +</tool_response> +``` + +Consecutive result blocks are newline-separated and placed in one synthesized `user` message. Result text is inserted verbatim. Image blocks from tool results are retained after the rendered text in that message. + +The generic XML protocol has **no success/error marker**. `renderToolResults` intentionally renders `isError: true` in the same `<tool_response>` shape as success; the error must be intelligible from its text. The model must never generate `<tool_response>` itself. + +## Thinking and visible text + +OMP renders preserved thinking as: + +```text +<thinking> +reasoning text +</thinking> +``` + +For the normal owned-tool stream, `parseThinking` is enabled. With the default Anthropic tagset, `<thinking>`, `<think>`, and `<scratchpad>` (including supported prefixed forms) become separate thinking events and do not appear in visible text. A direct scanner consumer that leaves `parseThinking` false sees those tags as text. An unterminated thinking block is logically closed on flush and retains its content. + +Visible prose may appear before or between unwrapped invokes. Inside a recognized `<tool_calls>` or `<function_calls>` wrapper, non-call text is discarded. + +## Scanner tagsets + +`XmlInbandScanner` delegates to one of two scanners according to `InbandScannerOptions.xmlTagset`: + +| `xmlTagset` | Scanner | Accepted call grammar | Argument rule | +| --- | --- | --- | --- | +| omitted or `anthropic` | `AnthropicInbandScanner` | Plain/`antml:` `<invoke>/<parameter>`, optionally inside `<tool_calls>` or `<function_calls>` | Tool schema determines strings; `string` attribute can override | +| `dsml` | `DeepSeekInbandScanner` | Pipe-wrapped DSML envelope and invokes (plus that scanner's DeepSeek token grammar) | Parameters default to strings; only `string="false"` requests JSON coercion | + +A direct API consumer can request DSML parsing: + +```ts +import { createInbandScanner } from "@oh-my-pi/pi-ai/dialect"; + +const scanner = createInbandScanner("xml", { + xmlTagset: "dsml", + parseThinking: true, +}); +``` + +DSML accepts fullwidth-pipe tags: + +```text +<|DSML|tool_calls> +<|DSML|invoke name="read"> +<|DSML|parameter name="path" string="true">src/a.ts</|DSML|parameter> +<|DSML|parameter name="count" string="false">2</|DSML|parameter> +</|DSML|invoke> +</|DSML|tool_calls> +``` + +It also accepts ASCII-pipe equivalents such as `<|DSML|tool_calls>`. In DSML mode, `string="false"` parses repaired JSON; invalid JSON falls back to the raw string. DSML thinking uses `<think>…</think>` and is parsed by default unless `parseThinking: false`. + +`xmlTagset` changes **only scanner selection**. The `xml` definition's call, result, thinking, and transcript renderers always emit the generic plain-XML forms described above. The normal `tools.format: xml` owned-stream path does not pass `xmlTagset`, so it uses the Anthropic tagset. OMP currently uses the DSML selector for stream-markup healing of leaked DSML output, not to change the `tools.format: xml` renderer. + +## Streaming, malformed output, and recovery + +### Default Anthropic tagset + +Parsing is incremental and safe across provider chunk boundaries. For every non-empty `<invoke name="…">`, the scanner: + +1. emits `toolStart` as soon as the opening invoke tag is complete; +2. emits keyed `toolArgDelta` events while parameter bodies stream; and +3. performs final coercion and emits `toolEnd` only after the matching `</invoke>`. + +The completed event includes the exact raw invoke block for diagnostics. Wrapper text is not part of that raw block. + +Failure behavior is explicit: + +- an invoke with a missing/blank name emits no tool lifecycle; +- a parameter with a missing/blank name is ignored; +- malformed JSON falls back to the original text; +- parameter content is capped at 1,000,000 JavaScript string code units, with an explicit truncation marker appended on overflow; +- an incomplete parameter or invoke emits no `toolEnd` when flushed; and +- complete invokes remain valid even when the outer wrapper never closes. + +OMP's stream projector creates a canonical call at `toolStart`, before `toolEnd`. Therefore, on a normally stopped provider response, an unterminated invoke can remain as a partial runnable call: streamed argument text stays uncoerced, or arguments are `{}` if none arrived. A provider `length` stop remains non-runnable `length`. This behavior applies to the ordinary owned `xml` path and is important when diagnosing model output that stops mid-tag. + +### DSML tagset + +The DSML scanner also streams each parameter as keyed deltas and emits `toolEnd` only at `</|DSML|invoke>` or its ASCII equivalent. An incomplete DSML parameter resets the partial call on flush without a completed event. Because `xmlTagset: dsml` is a direct scanner option rather than the normal owned-renderer path, callers consuming those events own the handling of an unmatched `toolStart`. + +### Fabricated results + +For the generic XML dialect, the first model-authored `<tool_response>` is treated as a fabricated-result boundary. OMP preserves calls/text before it and stops projection there. The default `tools.abortOnFabricatedResult: true` aborts provider generation; disabling the setting drains but discards the fabricated continuation. + +## End-to-end example + +Injected catalog line: + +```text +<tools> +{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"},"days":{"type":"number"}},"required":["city"]}}} +</tools> +``` + +Assistant call batch: + +```text +I'll compare both cities. +<invoke name="get_weather"><parameter name="city">Tokyo</parameter><parameter name="days">2</parameter></invoke> +<invoke name="get_weather"><parameter name="city">Oslo</parameter><parameter name="days">2</parameter></invoke> +``` + +Next user turn produced by OMP: + +```text +<tool_response> +{"forecast":["clear","rain"]} +</tool_response> +<tool_response> +{"forecast":["rain","cloudy"]} +</tool_response> +``` + +The assistant then answers normally or emits another sequence of invokes. + +## Parsing notes and gotchas + +- **Not real XML.** Parameter bodies are delimiter-matched and intentionally unescaped. An XML parser/entity decoder changes their values. +- **Renderer and scanner acceptance differ.** OMP renders bare consecutive invokes; the default scanner additionally accepts two wrappers and `antml:` variants. +- **No call ids or result names.** Preserve call/result order across a parallel batch. +- **Errors are text only.** Generic `<tool_response>` does not encode `isError`. +- **Schema context matters.** Supply tools to renderer/scanner APIs so schema-declared strings remain literal rather than JSON-quoted/coerced. +- **`xmlTagset` is scanner-only.** Selecting DSML does not make the XML renderer emit DSML. +- **A close tag finalizes the call.** `toolStart` and argument deltas stream early, but only `</invoke>` produces the final coerced argument object and `toolEnd`. + +## Sources + +- `packages/ai/src/dialect/xml.md` — injected generic XML format guide. +- `packages/ai/src/dialect/xml.ts` — renderer definitions and Anthropic/DSML scanner selection. +- `packages/ai/src/dialect/anthropic.ts` — default incremental invoke/parameter scanner, coercion, thinking, and incomplete-call behavior. +- `packages/ai/src/dialect/deepseek.ts` — DSML envelope scanner and `string="false"` coercion. +- `packages/ai/src/dialect/catalog.ts` and `prompt-template.md` — tool catalog and system-prompt injection. +- `packages/ai/src/dialect/rendering.ts`, `history.ts`, and `owned-stream.ts` — result rendering, history conversion, projection, and fabricated-result handling. +- `packages/ai/src/utils/stream-markup-healing.ts` — current DSML scanner integration. +- `packages/coding-agent/src/sdk.ts` — `tools.format` resolution. +- `packages/ai/test/inband-tools.test.ts` and `dialect-thinking.test.ts` — round trips, chunked argument deltas, raw blocks, result rendering, and thinking behavior. diff --git a/docs/tools/debug.md b/docs/tools/debug.md index 4a1ec551b..b96a1824a 100644 --- a/docs/tools/debug.md +++ b/docs/tools/debug.md @@ -161,7 +161,7 @@ Side-channel artifacts outside the model tool result: - `attach`: explicit `adapter` wins; otherwise remote `port` prefers `debugpy`, then native debuggers, then first available adapter. - **Custom adapter config** - Debug adapters can be added or overridden with `dap.json`, `.dap.json`, `dap.yaml`, `.dap.yaml`, `dap.yml`, or `.dap.yml`. - - Search order mirrors LSP config: project root, project config dirs (`.omp/`, `.pi/`, `.claude/`), user config dirs, plugin roots, then home-root fallback. Files are merged from lowest to highest priority. + - Search order mirrors LSP config: project root, project config dirs (`.omp/`, `.claude/`, `.codex/`, `.gemini/`), user config dirs (`~/.omp/agent/`, `~/.claude/`, `~/.codex/`, `~/.gemini/`), plugin roots, then home-root fallback. Files are merged from lowest to highest priority. - Config shape may be either `{ "adapters": { ... } }` or a top-level adapter map. - Adapter fields: - `command`: executable name or path. Required. diff --git a/docs/tools/eval.md b/docs/tools/eval.md index 78a211dfa..26347a1e5 100644 --- a/docs/tools/eval.md +++ b/docs/tools/eval.md @@ -155,7 +155,7 @@ Runs one subagent through `runStructuredSubagent(...)`: - `isolated` requests isolation. `apply` controls whether captured changes are integrated; `merge=false` selects patch mode while the normal setting controls branch mode. - `handle=true` returns `{ text, output, handle, id, agent }`, optional parsed `data`, and isolation metadata instead of only output/data. - Eval subagents are one-shot (`keepAlive=false`), are unregistered/disposed after completion, and **do not share the caller's eval executor** (`shareEvalSession=false`). Their code mutations therefore do not appear in the caller's retained VM/kernel. -- Spawn policy, discovered-agent availability, task-depth limit 3, hard turn budget, subagent failure, strict schema failure, and isolation-apply failure are enforced as cell errors. +- Spawn policy, discovered-agent availability, the `task.maxRecursionDepth` gate (default `2`; negative values disable the cap), hard turn budget, subagent failure, strict schema failure, and isolation-apply failure are enforced as cell errors. `parallel(thunks)` runs zero-argument callables in a bounded pool and preserves input order. `pipeline(items, ...stages)` applies each stage as a barriered wave. Pool width is read live from `task.maxConcurrency`; `0` means all items at once. The lowest-index failure is propagated. @@ -173,7 +173,7 @@ Runs one subagent through `runStructuredSubagent(...)`: - Output sink default window: 50 KiB (`DEFAULT_MAX_BYTES`); live tail: 100 KiB; truncation helpers cap at 3000 lines. - Each JSON display value included in model-visible text is capped at 8000 characters; the full structured value remains in `jsonOutputs`. - Transcript preview defaults to 10 lines. -- Eval subagent recursion cap: 3. Helper fan-out uses `task.maxConcurrency` (default 32, `0` unbounded). +- Eval subagent spawning obeys `task.maxRecursionDepth` (default `2`; negative values allow unlimited depth). Helper fan-out uses `task.maxConcurrency` (default 32, `0` unbounded). - Malformed params are schema errors; unavailable/disabled backends and missing session are `ToolError`s. - Runtime exceptions become backend output with nonzero exit. Interactive stdin is an error. Output truncation does not fail the call. - A dead retained managed kernel may be replaced and the invocation retried once by its executor. diff --git a/docs/tools/github.md b/docs/tools/github.md index aa390eff1..d6f1f9139 100644 --- a/docs/tools/github.md +++ b/docs/tools/github.md @@ -133,7 +133,7 @@ Branches: Worktree and metadata behavior: - Local branch name is always `pr-<number>`. -- Worktree path is `getWorktreeDir("<number>-<repo-hash>")` = `path.join(getWorktreesDir(), "<number>-<repo-hash>")`, where `getWorktreesDir()` is `~/.omp/wt`, `<number>` is the PR number, and `<repo-hash>` is `hashPath(primaryRepoRoot)` (a 7-hex digest of the primary repo root); effective path is `~/.omp/wt/<number>-<repo-hash>`. `resolveAvailableWorktreePath()` appends a `-2`/`-3`… suffix when that path is already registered with git or present on disk. +- Worktree path is `getWorktreeDir("<number>-<repo-hash>")` = `path.join(getWorktreesDir(), "<number>-<repo-hash>")`, where `<number>` is the PR number and `<repo-hash>` is `hashPath(primaryRepoRoot)` (a 7-hex digest of the primary repo root). `getWorktreesDir()` resolves the base in this order: a valid `OMP_WORKTREE_DIR`, the applied `worktree.base` setting, then the profile/XDG-aware data-root default (normally `~/.omp/wt`). Both overrides expand a leading `~` and must resolve to an absolute path; an invalid relative value is ignored and resolution falls through. `resolveAvailableWorktreePath()` appends a `-2`/`-3`… suffix when the resulting path is already registered with git or present on disk. - Existing worktree detection is by branch ref `refs/heads/pr-<number>` from `git.worktree.list()`. - New worktree creation calls `git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal })` after verifying the path is neither already registered nor already present on disk. - For same-repo PRs, remote is `origin`. For cross-repo PRs, the tool resolves a clone URL for the head repo, reuses an existing remote with the same URL when possible, or creates `fork-<owner>` / `fork-<owner>-<n>`. @@ -242,7 +242,7 @@ Watch flow: ## Side Effects - Filesystem - `pr_create` may create a temp dir under `os.tmpdir()` named `gh-pr-body-*`, write `body.md`, then remove the dir in `finally`. - - `pr_checkout` may create worktree directories named `<pr-number>-<repo-hash>` directly under `~/.omp/wt/` and add git worktrees there. + - `pr_checkout` may create worktree directories named `<pr-number>-<repo-hash>` under the base selected by `OMP_WORKTREE_DIR`, then `worktree.base`, then the profile/XDG-aware default (normally `~/.omp/wt`), and add git worktrees there. - `run_watch` may write a session artifact with full failed-job logs. - Network - Every op shells out to `gh`, which then talks to GitHub APIs except `pr_push`. diff --git a/docs/tools/lsp.md b/docs/tools/lsp.md index 2a725565b..5bf1f5f22 100644 --- a/docs/tools/lsp.md +++ b/docs/tools/lsp.md @@ -131,7 +131,7 @@ Uses the same location normalization and output shape as `definition`, but sends ### `symbols` **Inputs** -- Workspace mode: `file: "*"` or omitted file on the early workspace branch, plus required `query`. +- Workspace mode: required `file: "*"`, plus required `query`. Omitting `file` currently returns `Error: file parameter required...` before workspace-symbol dispatch. - Document mode: required `file`. - Optional: `timeout`. diff --git a/docs/tools/task.md b/docs/tools/task.md index cee4749c2..c206198d9 100644 --- a/docs/tools/task.md +++ b/docs/tools/task.md @@ -26,9 +26,9 @@ ## Inputs -The wire schema is shape-swapped by `task.batch` (default on). One unit of work is the task item `{ name?, agent?, task, effort?, outputSchema?, schemaMode?, isolated? }`. `isolated` exists only when `task.isolation.mode` is not `none`; `effort` exists only when `task.enableEffort=true` (default off). +The wire schema is shape-swapped by `task.batch` (default on). One unit of work is the task item `{ name?, agent?, task, effort?, outputSchema?, schemaMode?, isolated? }`. `isolated` exists only when `task.isolation.mode` is not `none` **and plan mode is disabled**; `effort` exists only when `task.enableEffort=true` (default off). -- **Batch shape** (`task.batch` on): `{ context, tasks: item[] }` — one subagent per item, all run under the same fan-out rules; there is no top-level agent field. `context` is **required** shared background rendered into every spawned subagent's system prompt (`CONTEXT` section); `agent`, `outputSchema`, and `schemaMode` are per item, with `effort`/`isolated` added only when their settings enable them. +- **Batch shape** (`task.batch` on): `{ context, tasks: item[] }` — one subagent per item, all run under the same fan-out rules; there is no top-level agent field. `context` is **required** shared background rendered into every spawned subagent's system prompt (`CONTEXT` section); `agent`, `outputSchema`, and `schemaMode` are per item. `effort` is added only when its setting enables it; `isolated` additionally requires plan mode to be disabled. - **Flat shape** (`task.batch` off): `{ ...item }` — exactly one spawn per call. Shared background goes into a `local://` file (e.g. `local://ctx.md`) that each spawn's `task` references; subagents share the parent's `local://` root. | Field | Type | Required | Description | @@ -41,7 +41,7 @@ The wire schema is shape-swapped by `task.batch` (default on). One unit of work | `effort` | `"lo" \| "med" \| "hi"` | No | Present only with `task.enableEffort=true`. Per-spawn thinking effort, mapped onto the resolved model's supported range (lowest/middle/highest level it tops out at, e.g. `high`/`xhigh`/`max`). Overrides the agent's default selector, including `auto`; omitting it keeps the agent's configured selector — automatic per-prompt classification only for agents configured `auto` (e.g. the bundled `task`); `scout`/`sonic` configure `medium`. Item field in batch shape, top-level in flat shape. | | `outputSchema` | JSON Schema (`object \| boolean \| string \| null` at the coarse wire-validation layer) | No | Invocation-specific structured-output contract. Takes precedence over agent frontmatter `output` and the inherited parent session schema. Item field in batch shape, top-level in flat shape. | | `schemaMode` | `"permissive" \| "strict"` | No | Validation mode for the effective output schema. Overrides the parent session mode; defaults to `permissive`. Item field in batch shape, top-level in flat shape. | -| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Exists only when `task.isolation.mode` is not `none`; per item in batch shape, top-level in flat shape. Isolated agents are torn down at completion — not revivable. | +| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Exists only when `task.isolation.mode` is not `none` and plan mode is disabled; per item in batch shape, top-level in flat shape. Isolated agents are torn down at completion — not revivable. | There is no wire label field: the one-line UI label shown in the TUI/registry is generated automatically from the `task` text by the tiny/title model (fire-and-forget), so callers never provide it. @@ -84,7 +84,7 @@ Artifacts and side channels: 4. Background execution (any non-blocking item with `async.enabled=true` and an `AsyncJobManager`): - agent ids are allocated up front via `AgentOutputManager.allocate(...)` — each item's `name`, or a generated AdjectiveNoun name — one per spawn; - one `type: "task"` job per spawn is registered with `session.asyncJobManager` (`id` = agent id, `queued: true`, `ownerId` = caller agent id) and the tool returns immediately; - - each job body acquires the session-scoped `Semaphore` (one per `TaskTool` instance, sized from `task.maxConcurrency` at first use), marks the job running, runs `#executeSync(...)` with that spawn's params, and reports progress through the shared `buildAsyncDetails`/`onUpdate`; + - each job body acquires the session-scoped `Semaphore` (one per `TaskTool` instance, resized in place from the live `task.maxConcurrency` setting before every acquire and release), marks the job running, runs `#executeSync(...)` with that spawn's params, and reports progress through the shared `buildAsyncDetails`/`onUpdate`; - a failed or aborted run throws `TaskJobError` so the job lands `failed`, but the agent itself stays registered and interrogable. - a mixed call registers the async jobs first, then runs its blocking items inline and returns once they settle — the text combines the inline summaries with the spawned-job listing, and the block keeps rendering the still-running background rows beside the inline results. 5. `#executeSync(...)` runs the spawn path (`#runSpawn`), which rediscovers agents from disk, so runtime resolution can differ from the create-time description. @@ -109,8 +109,8 @@ Artifacts and side channels: - Background job — `async.enabled=true`; non-blocking spawns go through `AsyncJobManager`. - Sync inline — `async.enabled=false`, no job manager, or the item's agent declares `blocking: true` (per item: a mixed call runs both modes). - Batch mode (`task.batch`, default on) - - on — `{ context, tasks[] }`: one independent spawn per item, required `context` shared across the call's spawns, with `agent`, `outputSchema`, and `schemaMode` per item; `isolated` and `effort` appear only when their settings enable them. Lifecycle, revival, and concurrency semantics match N parallel single calls. - - off — single spawn per call; `tasks`/`context` are rejected and removed from the schema, with the same setting-dependent `isolated`/`effort` fields. + - on — `{ context, tasks[] }`: one independent spawn per item, required `context` shared across the call's spawns, with `agent`, `outputSchema`, and `schemaMode` per item. `effort` appears only when its setting enables it; `isolated` also requires plan mode to be disabled. Lifecycle, revival, and concurrency semantics match N parallel single calls. + - off — single spawn per call; `tasks`/`context` are rejected and removed from the schema, with the same conditional `effort`/`isolated` fields. - Isolation mode (`task.isolation.mode`): `none`, `auto`, `apfs`, `btrfs`, `zfs`, `reflink`, `overlayfs`, `projfs`, `block-clone`, `rcopy` (legacy `worktree`, `fuse-overlay`, `fuse-projfs` accepted for back-compat); the PAL resolves the actual backend with fallback. - Isolation merge strategy: patch mode (capture/apply root patches) or branch mode (commit to `omp/task/<id>`, cherry-pick into parent). - Agent source precedence is first-wins by exact name: project `.omp/agents`; user `.omp/agent/agents`; OMP extension-package `agents/` roots in CLI → project settings → user settings → installed npm/link plugin order; Claude marketplace plugin agents (project before user); then bundled (`scout`, `designer`, `reviewer`, `security-reviewer`, `librarian`, `task`, `sonic`). @@ -139,7 +139,7 @@ Artifacts and side channels: ## Limits & Caps - Per-spawn effort is opt-in: `task.enableEffort` defaults to `false`; when false, `effort` is omitted from the dynamic model-facing schema. -- Concurrency: one session-scoped `Semaphore` sized from `task.maxConcurrency` at first use (later setting changes do not resize it) bounds concurrent subagents across parallel `task` calls — both async job bodies and the sync fallback acquire it. +- Concurrency: one session-scoped `Semaphore` is resized in place from the live `task.maxConcurrency` setting before every acquire and release, then bounds concurrent subagents across parallel `task` calls — both async job bodies and the sync fallback acquire it. Mid-session setting changes therefore affect new spawns and work already queued on the semaphore. - Idle TTL: `task.agentIdleTtlMs`, default `420_000` ms (7 min); `<= 0` disables parking and keeps idle sessions live until exit. - Per-subagent output truncation: `MAX_OUTPUT_BYTES = 500_000` and `MAX_OUTPUT_LINES = 5000` in `packages/coding-agent/src/task/types.ts` (overridable via `PI_TASK_MAX_OUTPUT_BYTES` / `PI_TASK_MAX_OUTPUT_LINES`). Full raw output is still written to `<id>.md`. - Progress coalescing: `PROGRESS_COALESCE_MS = 150`; recent-output tail: `RECENT_OUTPUT_TAIL_BYTES = 8 * 1024` (last 8 non-empty lines). diff --git a/docs/tools/todo.md b/docs/tools/todo.md index 98db7ee5c..424359a40 100644 --- a/docs/tools/todo.md +++ b/docs/tools/todo.md @@ -32,7 +32,7 @@ The params object **is** a single op — the discriminator and its fields live a | Field | Type | Required | Description | | --- | --- | --- | --- | -| `op` | `"init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view"` | Yes in the schema | Operation discriminator. At execution time, an omitted op is repaired only for unambiguous `list`/`items` payloads (see Flow). | +| `op` | `"init" \| "start" \| "done" \| "rm" \| "drop" \| "block" \| "unblock" \| "append" \| "view"` | Yes in the schema | Operation discriminator. At execution time, an omitted op is repaired only for unambiguous `list`/`items` payloads (see Flow). | | `list` | `{ phase: string; items: string[] }[]` | For `init` (unless a flat `items` list is given) | Full replacement payload. Each `items` array has `minItems: 1`. | | `task` | `string` | For `start`; for task-targeted `done`/`drop`/`block`/`unblock`/`rm` | Exact task content match. | | `phase` | `string` | For `append`; for phase-targeted `done`/`drop`/`block`/`unblock`/`rm`; optional for a flat `init` | Exact phase name match, except `append` lazily creates a missing phase and a flat `init` synthesizes one (default `Tasks`). | diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index ab97bb2ac..efab30d03 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -234,7 +234,8 @@ Each provider search transport receives a hard timeout from `providers.webSearch - Calls one or more external search providers over HTTPS until one succeeds or all fail. - Provider-specific transports include JSON POST, JSON GET, SSE streaming (Perplexity OAuth/API, Gemini, Codex), and JSON-RPC over HTTP (Z.AI). - Subprocesses / native bindings - - None. + - Most HTTP/API adapters spawn nothing. Google, Ecosia, and Mojeek first try a plain fetch, but failed, non-2xx, or challenged production responses can acquire the project-shared broker-owned headless Chromium. Hosts without a CLI worker entry (such as an embedded SDK host) instead launch process-local Chromium. + - This fallback can start a Chromium process and create its browser-profile lifecycle. On first browser use it can also download Chromium into the omp Puppeteer cache unless a system Chromium or `PUPPETEER_EXECUTABLE_PATH` is available. The search adapter itself uses no native binding. - Session state (transcript, memory, jobs, checkpoints, registries) - Uses a module-global provider-instance cache in `packages/coding-agent/src/web/search/provider.ts`. - Uses a module-global preferred-provider setting in the same file. diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index edde939bd..8f47ed160 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -29,14 +29,18 @@ which implements the commit-boundary seam described below. > when it was safe to rewrite native scrollback, and every policy choice over > that unobservable variable traded one failure family for another (yank ↔ > flash ↔ corruption ↔ invisible-until-resize — see the git history of this -> file for the full war journal). The current engine removes the guess -> entirely: **native scrollback is append-only.** +> file for the full war journal). The default engine removes the guess entirely: +> **native scrollback is append-only.** An opt-in divergence-rebuild mode can +> instead clear and replay scrollback outside multiplexers when finalized +> content no longer matches committed history (§2); it does not probe viewport +> position. We keep the transcript on the **normal screen** (native scrollback, native selection, transcript persists after exit). The engine maintains one ledger: - **`committedRows` (C)** — frame rows `[0, C)` have entered terminal history. - They are immutable: emitters never rewrite them. + Ordinary emitters never rewrite them. An opt-in destructive divergence replay + clears the ledger and rebuilds history from the current frame. - **`windowTopRow` (W)** — the frame row mapped to grid row 0. The visible window is frame rows `[W, W + height)`, repainted with relative cursor moves. - **live-region boundary (B)** — the first row that may still mutate, reported @@ -49,9 +53,9 @@ For an ordinary unpinned frame, `W = max(C, L - height)` and the new commit end is `max(C, W)`, clamped to the frame. The only bytes that enter history are the chunk between the old and new commit indices. Exact rows remain subject to the committed-prefix audit; frozen mutable snapshots are deliberately outside the -exactness claim. Scrollback therefore records every committed row once, in -order, with its bytes at commit time. The renderer never needs to know whether -the user has scrolled away from the tail. +exactness claim. In the default mode, scrollback therefore records every +committed row once, in order, with its bytes at commit time. The renderer never +needs to know whether the user has scrolled away from the tail. ### What this costs (the accepted tradeoffs) @@ -78,29 +82,39 @@ the user has scrolled away from the tail. rows in the last 24, SGR-stripped). A single in-place mismatch is accepted as stale history; a structural shift re-anchors at the first changed row, favoring duplication over content loss. -3. Classify the frame as a gesture-driven full paint or ordinary update and - calculate the window/commit chunk. Overlays freeze commits. A pinned live - region clips its offscreen mutable suffix instead of snapshotting it. +3. Classify the frame as a gesture-driven full paint, an opt-in divergence + rebuild, or an ordinary update and calculate the window/commit chunk. + Overlays freeze commits. A pinned live region clips its offscreen mutable + suffix instead of snapshotting it. 4. Extract cursor markers, prepare width-safe lines, slice the window, and composite overlays into the screen-coordinate window only. 5. Emit: -| Emitter | Bytes | When | -| ---------------------------- | -------------------------------------------------- | ----------------------------------------------------------- | -| `#emitFullPaint` | home + committed chunk + window rows; optional ED3 | initial paint and explicit geometry/session/reset gestures | -| `#emitUpdate` scroll-append | new bottom rows plus changed-row range | rows leaving the screen are exactly the commit chunk | -| `#emitUpdate` in-window diff | relative move plus changed-row rewrite | nothing scrolls or commits | -| `#emitUpdate` seam rewrite | commit chunk plus full window rewrite | commit/window re-anchor, hidden-gap backfill, or mux resize | +| Emitter | Bytes | When | +| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------------- | +| `#emitFullPaint` | home + committed chunk + window rows; optional ED3 | initial paint, explicit geometry/session/reset gestures, or rebuild | +| `#emitUpdate` scroll-append | new bottom rows plus changed-row range | rows leaving the screen are exactly the commit chunk | +| `#emitUpdate` in-window diff | relative move plus changed-row rewrite | nothing scrolls or commits | +| `#emitUpdate` seam rewrite | commit chunk plus full window rewrite | commit/window re-anchor, hidden-gap backfill, or mux resize | -**ED3 (`CSI 3 J`) is emitted in exactly one place** — `#emitFullPaint` with -`clearScrollback: true` — and is reached only by user gestures: session -replace/branch/resume (`requestRender(true, { clearScrollback: true })`), -resize outside a multiplexer, `resetDisplay()` (the display-reset chord, -`Alt+L` by default). It clears native -history without `ED2` first; the replay overwrites every row from home so -terminals without synchronized output do not expose a blank viewport. A gesture -pins the user to the tail, so the history snap is acceptable; multiplexers never -get ED3 (it is a no-op there and a replay would duplicate pane history). +**ED3 (`CSI 3 J`) is emitted in exactly one place** — +`#emitFullPaint({ clearScrollback: true })`. The normal callers are explicit +user gestures: session replace/branch/resume +(`requestRender(true, { clearScrollback: true })`), resize outside a +multiplexer, and `resetDisplay()` (the display-reset chord, `Alt+L` by +default). It clears native history without `ED2` first; the replay overwrites +every row from home so terminals without synchronized output do not expose a +blank viewport. A gesture pins the user to the tail, so the history snap is +acceptable. + +The second caller is an ordinary-render divergence when +`tui.scrollbackRebuild` is enabled: if the committed prefix structurally +resynchronizes or the current frame collapses into committed rows, the renderer +clears and replays the current frame to replace stale preview history with the +final form. This path is disabled by default and never runs after the first +paint, during an explicit replacement/geometry frame, or inside a multiplexer. +Multiplexers never get ED3 (it is a no-op there and a replay would duplicate +pane history). The ordinary update path never emits ED2/ED3 or an absolute cursor home — several terminal families snap a scrolled reader to the bottom on those. @@ -141,12 +155,13 @@ contract, not a terminal-specific optimization. ## 3. Invariants — MUST / NEVER 1. **NEVER add a new `CSI 3 J` (ED3) callsite.** ED3 flows only through - `#emitFullPaint({ clearScrollback: true })`, only for gestures, never inside - multiplexers. -2. **NEVER rewrite a committed row.** Emitters treat frame rows `< C` as - immutable. A shrink or structural resync may re-anchor below the old commit - point, but stale history remains and new bytes are appended; it is never - erased or silently skipped. + `#emitFullPaint({ clearScrollback: true })`, for explicit gestures or the + guarded opt-in divergence rebuild, and never inside multiplexers. +2. **Ordinary emitters NEVER rewrite a committed row.** They treat frame rows + `< C` as immutable. A shrink or structural resync may re-anchor below the old + commit point, but in default mode stale history remains and new bytes are + appended; it is never silently skipped. The opt-in divergence rebuild is the + deliberate exception: it clears and replays the complete current frame. 3. **Commits are exactly the chunk.** Any byte shape that scrolls the screen must scroll only rows accounted for by the commit advance. 4. **NEVER probe the viewport position or fork on platform in the update @@ -284,17 +299,18 @@ default-on only for kitty/ghostty (`PI_NO_KITTY_PLACEHOLDERS` / ## 9. Escape hatches (env vars) -| Var | Effect | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PI_NO_SYNC_OUTPUT=1` | Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). | -| `PI_TUI_SYNC_OUTPUT=0\|1` / `PI_FORCE_SYNC_OUTPUT=1` | Force sync output off / on. | -| `PI_NO_DECCARA` | Disable Kitty DECCARA rectangular-fill optimization. | -| `PI_FORCE_IMAGE_PROTOCOL=kitty\|iterm2\|sixel\|off` | Override image protocol detection. | -| `PI_NO_KITTY_PLACEHOLDERS=1` / `PI_KITTY_PLACEHOLDERS=1` | Force Kitty Unicode placeholders off / on. | -| `PI_HARDWARE_CURSOR=1` | Show the real hardware cursor instead of a rendered one. | -| `PI_NOTIFICATIONS=off\|0\|false` | Suppress terminal notifications. | -| `PI_DEBUG_REDRAW=1` | Log the chosen render intent + ledger state per frame to the debug log. | -| `PI_TUI_RESIZE_IN_PLACE=1\|0` | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). | +| Var | Effect | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PI_NO_SYNC_OUTPUT=1` | Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). | +| `PI_TUI_SYNC_OUTPUT=0\|1` / `PI_FORCE_SYNC_OUTPUT=1` | Force sync output off / on. | +| `PI_NO_DECCARA` | Disable Kitty DECCARA rectangular-fill optimization. | +| `PI_FORCE_IMAGE_PROTOCOL=kitty\|iterm2\|sixel\|off` | Override image protocol detection. | +| `PI_NO_KITTY_PLACEHOLDERS=1` / `PI_KITTY_PLACEHOLDERS=1` | Force Kitty Unicode placeholders off / on. | +| `PI_HARDWARE_CURSOR=1` | Show the real hardware cursor instead of a rendered one. | +| `PI_NOTIFICATIONS=off\|0\|false` | Suppress terminal notifications. | +| `PI_DEBUG_REDRAW=1` | Log the chosen render intent + ledger state per frame to the debug log. | +| `PI_TUI_RESIZE_IN_PLACE=1\|0` | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). | +| `PI_TUI_SCROLLBACK_REBUILD=1` | Initialize low-level `TUI` divergence rebuild on. Coding-agent subsequently applies `tui.scrollbackRebuild` (default `false`), so use the setting for interactive sessions. | Removed with the old engine: `PI_TUI_ED3_SAFE` (no ED3-risk lever exists), `PI_CLEAR_ON_SHRINK`, and `PI_TUI_DEBUG` (per-render dump superseded by @@ -304,8 +320,9 @@ Removed with the old engine: `PI_TUI_ED3_SAFE` (no ED3-risk lever exists), ## 10. Before you touch the render core — checklist -- [ ] Are you about to emit `CSI 3 J` anywhere other than the gesture-driven - `clearScrollback` full paint? **Stop.** +- [ ] Are you about to emit `CSI 3 J` anywhere other than the existing + `clearScrollback` full-paint path for a gesture or guarded divergence + rebuild? **Stop.** - [ ] Could an ordinary emitter rewrite a row below `committedRows`? **Stop.** - [ ] Does your byte shape scroll rows not accounted for by the commit chunk? That breaks the append-only ledger. diff --git a/docs/tui-runtime-internals.md b/docs/tui-runtime-internals.md index 82bb1c5db..2801d832d 100644 --- a/docs/tui-runtime-internals.md +++ b/docs/tui-runtime-internals.md @@ -18,6 +18,7 @@ Boundary rule: the TUI engine is message-agnostic. It only knows `Component.rend ## Implementation files - [`packages/coding-agent/src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts) +- [`packages/coding-agent/src/modes/session-teardown.ts`](../packages/coding-agent/src/modes/session-teardown.ts) - [`packages/coding-agent/src/modes/controllers/event-controller.ts`](../packages/coding-agent/src/modes/controllers/event-controller.ts) - [`packages/coding-agent/src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts) - [`packages/coding-agent/src/modes/components/custom-editor.ts`](../packages/coding-agent/src/modes/components/custom-editor.ts) @@ -69,6 +70,22 @@ A forced render (`requestRender(true)`) queues a viewport repaint or explicit se This prevents partial escape chunks from being misinterpreted as normal keypresses. +### Shutdown and terminal handoff + +Exit from double `Ctrl+C`, empty-editor `Ctrl+D`, `/exit`, and postmortem signals converges on a promise-memoized session teardown. The first caller wins: it snapshots the editor draft, calls `beginDispose()` synchronously, attempts to save the draft, and then disposes the session. A draft-save failure is logged but does not skip disposal; later keypress or signal callers await the same promise and cannot double-run shutdown. + +Interactive shutdown then follows this ownership order: + +1. `InteractiveMode` stops live commands and transient controllers, displays the closing status, and awaits session disposal before handing the terminal back. +2. It drains in-flight Kitty input for up to one second so release sequences do not leak into the parent shell. +3. It disposes the run-state title/spinner state and restores the prior terminal title before stopping the UI. +4. `TUI.stop()` leaves resize/fullscreen alternate-screen state, purges image/probe state, stops watchdog and render/resize timers, positions and forcibly restores the cursor, then delegates to `ProcessTerminal.stop()`. +5. `ProcessTerminal.stop()` restores real stderr and terminal modes, disables keyboard/mouse/appearance protocols, clears probes and timers, destroys `StdinBuffer`, removes stdin/stdout listeners, pauses stdin, and restores its previous raw-mode state. + +Terminal disconnects mark the terminal dead and stop interactive rendering. Cleanup still removes owned state, but raw-mode restoration errors are suppressed only for that dead-terminal case because there is no live TTY left to restore. + +Suspend is distinct from exit: `Ctrl+Z` stops the TUI to release terminal modes, sends `SIGTSTP`, and retains the session. Its one-shot `SIGCONT` handler starts the TUI again and forces a repaint; it does not run session teardown or terminal handoff to a parent shell. + ## Input routing and focus model Input path: @@ -99,7 +116,7 @@ Routing details: This keeps key parsing/editor mechanics in `packages/tui` and mode semantics in coding-agent controllers. -## Render loop and the append-only contract +## Render loop and the default append-only contract `TUI.requestRender()` coalesces render requests and rate-limits ordinary frames: @@ -115,9 +132,11 @@ This keeps key parsing/editor mechanics in `packages/tui` and mode semantics in 2. Audit the already committed raw prefix for structural shifts; an insertion/deletion re-anchors commits at the first changed row so stale history may duplicate but new content is not lost. 3. Advance the append-only ledger. Rows before the live boundary are exact/final; mutable rows that scroll above the window normally commit as frozen snapshots, while a pinned live region stays viewport-local. 4. Extract and strip `CURSOR_MARKER`, normalize lines, slice the visible window, and composite overlays into that screen-coordinate window slice (overlays freeze commits). -5. Emit one of: gesture-driven full paint, scroll-append, in-window row diff, or seam rewrite. +5. Emit one of: gesture-driven or divergence-rebuild full paint, scroll-append, in-window row diff, or seam rewrite. -Native scrollback is append-only: committed frame rows are never rewritten. Exact rows enter history after the component seam declares them final; an unpinned mutable row that scrolls off is recorded as the snapshot that was visible at commit time. There are no viewport-position probes or deferred reconciliation; see [`tui-core-renderer.md`](./tui-core-renderer.md). +By default, native scrollback is append-only: committed frame rows are never rewritten. Exact rows enter history after the component seam declares them final; an unpinned mutable row that scrolls off is recorded as the snapshot that was visible at commit time. There are no viewport-position probes or deferred reconciliation; see [`tui-core-renderer.md`](./tui-core-renderer.md). + +The opt-in `tui.scrollbackRebuild` setting (default `false`) changes how a committed-prefix divergence is repaired. When finalized content replaces a scrolled-off preview, or a frame collapses into already committed rows, a direct terminal session clears native scrollback with ED3 and replays the current frame so the stale and final forms do not both remain. Multiplexer sessions never take this destructive path and retain the append/repair-below fallback. `PI_TUI_SCROLLBACK_REBUILD=1` initializes the low-level `TUI` flag, but `InteractiveMode` then applies the configured `tui.scrollbackRebuild` value; the setting is therefore the effective control in coding-agent. Render writes use synchronized output mode (`CSI ? 2026 h/l`) when enabled; capability detection, DECRQM, or `PI_NO_SYNC_OUTPUT` can disable the wrappers while leaving autowrap discipline on. diff --git a/docs/tui.md b/docs/tui.md index 8fe3e9305..82f90af98 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -126,28 +126,42 @@ Behavior in interactive mode (`extension-ui-controller.ts`): - On `done(result)`: calls `component.dispose?.()`, hides the overlay if present, restores editor + text for non-overlay flows, focuses editor, resolves promise. So `done(...)` is mandatory for completion. -## 2) Hook/custom-tool UI context (legacy typing) +## 2) Hook/custom-tool UI context (runtime/type mismatch) -`HookUIContext.custom` is typed as `(tui, theme, done)` in hook/custom-tool types. -Underlying interactive implementation calls factories with `(tui, theme, keybindings, done)`. JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature. +`HookUIContext.custom` is still typed as `(tui, theme, done)`, but the +interactive controller invokes the factory as +`(tui, theme, keybindings, done)`. The third runtime argument is therefore a +`KeybindingsManager`, **not** the completion callback. A three-argument factory +that calls its third parameter will fail at runtime and leave the custom UI +unresolved. -Custom tools typically use the same UI entrypoint via the factory-scoped `pi.ui` object, then return the selected value in normal tool content: +Until the hook/custom-tool type is aligned with the controller, do not copy the +legacy three-argument examples from the type declaration. Runtime-safe +interactive code must obtain the completion callback from the fourth positional +argument, for example with a rest-argument adapter, and should guard the flow +with `pi.hasUI`: ```ts -async execute(toolCallId, params, onUpdate, ctx, signal) { - if (!pi.hasUI) { - return { content: [{ type: "text", text: "UI unavailable" }] }; - } - - const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => { - const component = new MyPickerComponent(done, signal); - return component; - }); - - return { content: [{ type: "text", text: picked ? `Picked: ${picked}` : "Cancelled" }] }; -} +const picked = await pi.ui.custom<string | undefined>( + (...runtimeArgs: unknown[]) => { + const done = runtimeArgs[3]; + if (typeof done !== "function") { + throw new Error( + "Interactive custom UI completion callback is unavailable", + ); + } + return new MyPickerComponent( + done as (value: string | undefined) => void, + signal, + ); + }, +); ``` +This is a compatibility workaround for the current implementation, not a +stable four-argument hook type. `ExtensionUIContext.custom`, described above, +has the supported four-argument contract. + ## 3) Custom tool call/result renderers Custom tools and extension tools can return components from: diff --git a/docs/user-facing-packages.md b/docs/user-facing-packages.md index e3b771722..7150b09bd 100644 --- a/docs/user-facing-packages.md +++ b/docs/user-facing-packages.md @@ -11,6 +11,17 @@ This page indexes README-only user-facing package CLIs and features that need ro ## Package CLIs and features +### `python/robomp` — self-hosted GitHub triage and fix service + +Sources: [`python/robomp/README.md`](../python/robomp/README.md), [`python/robomp/pyproject.toml`](../python/robomp/pyproject.toml), [`python/robomp/.env.example`](../python/robomp/.env.example), [`python/robomp/docker-compose.yml`](../python/robomp/docker-compose.yml). + +- Python package: `robomp` (Python 3.11 or newer); bin: `robomp`, with `serve`, `triage`, `replay`, `status`, and `cleanup` commands. +- Feature: self-hosted service that receives GitHub webhooks for allowlisted repositories, classifies issues, resumes an `omp --mode rpc` session per issue, comments or opens a fix PR, and handles follow-up issue and PR conversations. +- Dashboard/API: FastAPI serves the operator dashboard at `/` alongside health, event, issue, and replay endpoints. The bundled Compose deployment publishes it at `http://localhost:6543/`; `bun run robomp:web:dev` runs the dashboard frontend in development, and `bun run robomp:web:build` rebuilds its static bundle. +- Inputs/storage: configuration comes from `python/robomp/.env` and the mounted `~/.omp/agent/models.container.yml`; GitHub webhook events feed a SQLite-backed queue. The Compose deployment persists the database, per-issue worktrees, session transcripts, and logs in the `robomp_data` volume under `/data`. +- Root commands: `bun run robomp:install` installs the Python package for host development; `bun run robomp:serve` runs it on the host; `bun run robomp:build`/`bun run robomp:rebuild`, `bun run robomp:up`, `bun run robomp:down`, `bun run robomp:restart`, `bun run robomp:logs`, `bun run robomp:dev`, and `bun run robomp:reset` manage the container deployment. +- Prerequisites: Docker Compose v2, a host-reachable LiteLLM-style model proxy, container model configuration, a GitHub webhook endpoint, and a bot PAT with write access to every allowlisted repository. The default two-container deployment keeps the PAT in an HMAC-authenticated `gh-proxy` sidecar rather than the orchestrator. + ### `packages/swarm-extension` — swarm orchestration Sources: [`packages/swarm-extension/README.md`](../packages/swarm-extension/README.md), [`packages/swarm-extension/package.json`](../packages/swarm-extension/package.json), [`packages/swarm-extension/src/cli.ts`](../packages/swarm-extension/src/cli.ts), [`packages/swarm-extension/src/extension.ts`](../packages/swarm-extension/src/extension.ts). @@ -23,18 +34,6 @@ Sources: [`packages/swarm-extension/README.md`](../packages/swarm-extension/READ - Side effects/output: creates the workspace if needed and persists state/logs under `<workspace>/.swarm_<name>/`. - Limits/errors: validates the YAML definition, dependency graph, and cycles before execution; standalone runs have no built-in timeout. -### `packages/terminal-bench` — Terminal-Bench 2 runner - -Sources: [`packages/terminal-bench/README.md`](../packages/terminal-bench/README.md), [`packages/terminal-bench/package.json`](../packages/terminal-bench/package.json), [`packages/terminal-bench/src/runner.ts`](../packages/terminal-bench/src/runner.ts), [`packages/terminal-bench/agent/omp_local.py`](../packages/terminal-bench/agent/omp_local.py). - -- Package: private `@oh-my-pi/terminal-bench`; bin: `tb2`. -- Feature: runs `harbor-framework/terminal-bench-2` against a local or published `omp` build with a live progress, spend, token, ETA, and pass/fail dashboard. -- CLI: `bun src/runner.ts [options] [-- <extra harbor args>]`; package bin exposes `tb2`. -- Modes: default `omp` agent, `oracle`/`nop`/any Harbor agent via `--agent`; local source packing by default, published npm install via `--install published`; `cleanup` command removes leftover Harbor Docker resources. -- Key inputs: `--model`, `--tasks`, `--concurrency`, `--attempts`, `--include`, `--exclude`, `--dataset`, `--thinking`, `--advisor-model`, gateway options, `--tarball`, `--no-build`, `--dry-run`, and passthrough Harbor args. -- Outputs: Harbor job directories plus `_bench/<jobName>/report.md`, `harbor.log`, and generated `models.yml` under `--jobs-dir`. -- Side effects/limits: requires Docker, Harbor, and usually the host auth gateway; local install packs `packages/coding-agent`; web search is off by default because it cannot authenticate through the gateway; Alpine/musl task images are unsupported by the native prebuilds. - ### `packages/stats` — local usage dashboard Sources: [`packages/stats/README.md`](../packages/stats/README.md), [`packages/stats/package.json`](../packages/stats/package.json), [`packages/coding-agent/src/cli/stats-cli.ts`](../packages/coding-agent/src/cli/stats-cli.ts). @@ -47,32 +46,39 @@ 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/typescript-edit-benchmark` — TypeScript edit benchmark +### `packages/omptype` — schema validation library -Sources: [`packages/typescript-edit-benchmark/package.json`](../packages/typescript-edit-benchmark/package.json), [`packages/typescript-edit-benchmark/src/index.ts`](../packages/typescript-edit-benchmark/src/index.ts), [`packages/typescript-edit-benchmark/src/runner.ts`](../packages/typescript-edit-benchmark/src/runner.ts), [`packages/typescript-edit-benchmark/src/tasks.ts`](../packages/typescript-edit-benchmark/src/tasks.ts), [`packages/typescript-edit-benchmark/src/report.ts`](../packages/typescript-edit-benchmark/src/report.ts). +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). -There is no package README at this path today; the manifest and CLI entrypoint help are the cited package-local sources. +- 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. -- Package: private `@oh-my-pi/typescript-edit-benchmark`; bin: `typescript-edit-benchmark`. -- Feature: benchmark suite for evaluating coding-agent edit success on TypeScript source-code mutation fixtures. -- CLI: `bun run bench:edit [options]` in source help; package scripts also expose `bun run src/index.ts` through `start`. -- Key inputs: provider/model, thinking level, runs per task, timeout, task concurrency, task IDs, max tasks, fixture directory or `.tar.gz`, edit variant/fuzzy settings, guided mode, retry/turn limits, output path, report format, fixture validation, and required tool-call flags. -- Fixtures: each task directory contains `prompt.md`, `input/`, `expected/`, and `metadata.json`; bundled distribution can use `fixtures.tar.gz`. -- Outputs: markdown or JSON benchmark reports under `runs/` by default, with live progress and optional conversation dumps. -- Side effects/limits: creates the repository `runs/` directory, extracts fixture archives to temp space, and runs agent sessions against copied fixtures; `--check-fixtures` validates fixture structure and exits. +### `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). + +- Package: private `@oh-my-pi/typescript-edit-benchmark`; support library with no standalone bin. +- Feature: generates, loads, formats, and verifies TypeScript mutation fixtures consumed by the metaharness edit adapter. +- Fixture generation: `bun packages/typescript-edit-benchmark/src/generate.ts --typescript-dir <path> [generator options]` from the repository root. +- Benchmark execution: `bun run --cwd packages/metaharness bench:edit -- --model <provider/model> [options]`, or launch an `edit` run from the metaharness dashboard/API. +- Runner inputs include provider/model, thinking level, runs per task, timeouts, concurrency, task IDs, fixture directory or `.tar.gz`, edit strategy, guided mode, retry/turn limits, output path/format, and fixture validation/listing flags. +- Fixtures contain task metadata, a prompt, input files, and expected files. The runner copies each fixture to an isolated worktree, records optional conversation dumps, and writes Markdown or JSON results. ### `packages/metaharness` — unified benchmark manager -Sources: [`packages/metaharness/README.md`](../packages/metaharness/README.md), [`packages/metaharness/package.json`](../packages/metaharness/package.json), [`packages/metaharness/src/server.ts`](../packages/metaharness/src/server.ts). +Sources: [`packages/metaharness/README.md`](../packages/metaharness/README.md), [`packages/metaharness/package.json`](../packages/metaharness/package.json), [`packages/metaharness/src/server.ts`](../packages/metaharness/src/server.ts), [`packages/metaharness/src/runner.ts`](../packages/metaharness/src/runner.ts), and [`packages/metaharness/adapters/edit/cli.ts`](../packages/metaharness/adapters/edit/cli.ts). - Package: private `@oh-my-pi/pi-metaharness`; bin: `metaharness`. -- Feature: one dashboard, SQLite store, REST/SSE API, and normalized run/trace model for Harbor, - TypeScript edit, and SnapCompact benchmarks. -- CLI: `bun run serve --port 4700` (or the package bin) starts the dashboard and API. -- Storage: state lives under `<jobs-dir>/_manager/metaharness.sqlite`; benchmark-native artifacts - remain the filesystem source of truth and historical CLI runs are auto-discovered. -- Limits: deleting an experiment or run also deletes its job directories and is rejected while the - target is running. +- Feature: one dashboard, SQLite store, REST/SSE API, and normalized experiment → run → trace model for Harbor datasets (default `terminal-bench@2.0`), TypeScript edit, and SnapCompact benchmarks. +- Dashboard/API: `bun run --cwd packages/metaharness serve -- --port 4700`; the launch form and `POST /api/runs` support all three benchmark adapters. +- Direct runners: `bun packages/metaharness/src/runner.ts --model <provider/model> [Harbor options]` and `bun run --cwd packages/metaharness bench:edit -- --model <provider/model> [edit options]`. +- Harbor source mode bind-mounts the repository and a cached Linux dependency tree, while provider credentials stay on the host behind the auth gateway. Local-tarball, published-package, and prebuilt-binary install modes are also available. +- Storage: normalized state lives under `<jobs-dir>/_manager/metaharness.sqlite`; benchmark-native artifacts remain the filesystem source of truth and historical runs are auto-discovered. +- Outputs include Harbor trial directories, `_bench/<jobName>/report.md`, per-run logs, edit reports, normalized traces, dashboard metrics, and REST/SSE updates. +- Limits: deleting an experiment or run also deletes its job directories and is rejected while the target is running. Harbor requires Docker or Apple Container plus the Harbor CLI; backend-specific network and mount constraints are documented in the package README. ### `packages/browser-relay` — drive existing Chrome tabs @@ -109,3 +115,13 @@ Sources: [`packages/snapcompact/README.md`](../packages/snapcompact/README.md), - Public entrypoint includes `compact`, `render`, `renderMany`, `frames`, shape selection, text normalization/serialization, image budgets, and file-operation helpers. - Runtime constraint: rasterization and PNG encoding require `@oh-my-pi/pi-natives`. + +### `packages/mnemopi` — standalone local-memory CLI + +Sources: [`packages/mnemopi/README.md`](../packages/mnemopi/README.md), [`packages/mnemopi/package.json`](../packages/mnemopi/package.json), [`packages/mnemopi/src/cli.ts`](../packages/mnemopi/src/cli.ts), and the coding-agent [Mnemopi memory backend guide](./mnemosyne-memory-backend.md). + +- Package: public `@oh-my-pi/pi-mnemopi`; bin: `mnemopi`; requires Bun 1.3.14 or newer. Install globally with `bun add --global @oh-my-pi/pi-mnemopi`, then run `mnemopi <command>`. From a source checkout, `bun packages/mnemopi/src/cli.ts <command>` runs the same entrypoint. +- Store and search: `store`/`remember`, `recall`/`search`, `update`/`edit`, and `delete`/`forget`. +- Inspect and maintain: `stats`, `sleep`/`consolidate`, `diagnose`/`doctor`, JSON `export` and `import`, `scratchpad`/`sp` with `read`, `write`, or `clear`, and `bank` with `list`, `create`, or `delete`. +- Integration: `mcp` starts the package's MCP server. The standalone CLI operates directly on Mnemopi storage; select `memory.backend: mnemopi` instead when integrating memory into OMP sessions, as described in the backend guide. +- Discovery and errors: `mnemopi --help` lists primary command forms. Unknown commands and invalid arguments print a concise error and return a nonzero exit code. diff --git a/package.json b/package.json index 5bafac973..e763563f7 100644 --- a/package.json +++ b/package.json @@ -26,18 +26,19 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.2.6", - "@oh-my-pi/omp-stats": "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", - "@oh-my-pi/pi-coding-agent": "17.2.6", - "@oh-my-pi/pi-mnemopi": "17.2.6", - "@oh-my-pi/pi-natives": "17.2.6", - "@oh-my-pi/pi-tui": "17.2.6", - "@oh-my-pi/pi-utils": "17.2.6", - "@oh-my-pi/pi-wire": "17.2.6", - "@oh-my-pi/snapcompact": "17.2.6", + "@oh-my-pi/hashline": "17.2.8", + "@oh-my-pi/omp-stats": "17.2.8", + "@oh-my-pi/omptype": "17.2.8", + "@oh-my-pi/pi-agent-core": "17.2.8", + "@oh-my-pi/pi-ai": "17.2.8", + "@oh-my-pi/pi-catalog": "17.2.8", + "@oh-my-pi/pi-coding-agent": "17.2.8", + "@oh-my-pi/pi-mnemopi": "17.2.8", + "@oh-my-pi/pi-natives": "17.2.8", + "@oh-my-pi/pi-tui": "17.2.8", + "@oh-my-pi/pi-utils": "17.2.8", + "@oh-my-pi/pi-wire": "17.2.8", + "@oh-my-pi/snapcompact": "17.2.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -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/package.json b/packages/agent/package.json index 91437ea6d..effde2654 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "17.2.6", + "version": "17.2.8", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -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<undefined> { 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 abfe680ff..3f4da9b40 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [17.2.7] - 2026-08-03 + +### Changed + +- Replaced `arktype` with `@oh-my-pi/omptype` for schema validation, delivering up to 100x faster schema construction and 60-100x faster validation while maintaining full compatibility with existing `type`/`Type` exports and the `isArkSchema` contract. + +### Fixed + +- Fixed OpenAI-Codex (ChatGPT OAuth) requests failing with an `Unsupported service_tier: auto` error on default or legacy sessions by omitting the implicit `auto` service tier on the wire. +- Fixed an issue where Cursor `kimi-k3` sessions would break permanently when a same-model assistant turn was persisted without thinking blocks, replacing hard errors with graceful warnings. + ## [17.2.6] - 2026-08-03 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 5f07b826f..6d9876dad 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "17.2.6", + "version": "17.2.8", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -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/cursor.ts b/packages/ai/src/providers/cursor.ts index 119e50869..f1c466041 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -144,6 +144,7 @@ import { isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { $env, + logger, parseJsonWithRepair, parseStreamingJson, parseStreamingJsonThrottled, @@ -233,6 +234,7 @@ const NOT_IMPLEMENTED = `Not implemented by this client`; const conversationStateCache = new Map<string, ConversationStateStructure>(); const conversationBlobStores = new Map<string, Map<string, Uint8Array>>(); +const warnedCursorKimiK3ReplayMessages = new Set<string>(); export interface CursorOptions extends StreamOptions { customSystemPrompt?: string; @@ -3902,6 +3904,15 @@ export function processInteractionUpdate( } } else if (updateCase === "turnEnded") { output.stopReason = "stop"; + if ( + isKimiK3ModelId(output.model) && + !output.content.some(item => item.type === "thinking" && item.thinking.length > 0) + ) { + logger.warn( + "Cursor kimi-k3 turn completed without thinking blocks; persisted history will replay this turn without reasoning", + { model: output.model, messageTimestamp: output.timestamp }, + ); + } } else if (updateCase === "tokenDelta") { const tokenDelta = update.message.value; usageState.sawTokenDelta = true; @@ -4113,17 +4124,34 @@ function assertCursorKimiK3HistoryReplayable( ): void { if (!targetModelId || !isKimiK3ModelId(targetModelId)) return; const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length; + const missingThinkingTurns: number[] = []; + const newlyWarnedKeys: string[] = []; + let assistantTurn = 0; for (let i = 0; i < historyEnd; i++) { const msg = messages[i]; if (msg.role !== "assistant") continue; + assistantTurn++; const isSameCursorModel = msg.api === "cursor-agent" && msg.provider === "cursor" && msg.model === targetModelId; - const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0); - if (!isSameCursorModel || !hasThinking) { + if (!isSameCursorModel) { + // Foreign history genuinely cannot replay K3 thinking: another model's + // turns carry no K3-signed reasoning to reconstruct. throw new AIError.ValidationError( - `Cursor ${targetModelId} requires complete same-model thinking history; start a new session instead of continuing history from ${msg.provider}/${msg.model}.`, + `Cursor ${targetModelId} cannot continue history from a different model (${msg.provider}/${msg.model}); start a new session.`, ); } + const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0); + if (hasThinking) continue; + const warningKey = `${msg.api}\0${msg.provider}\0${msg.model}\0${msg.timestamp}`; + if (warnedCursorKimiK3ReplayMessages.has(warningKey)) continue; + missingThinkingTurns.push(assistantTurn); + newlyWarnedKeys.push(warningKey); } + if (missingThinkingTurns.length === 0) return; + for (const key of newlyWarnedKeys) warnedCursorKimiK3ReplayMessages.add(key); + logger.warn( + `Cursor kimi-k3 history contains same-model assistant turn(s) ${missingThinkingTurns.join(", ")} without thinking blocks; replaying those spans without reasoning may make generation less stable`, + { model: targetModelId, assistantTurns: missingThinkingTurns }, + ); } /** 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 8a7cb8b18..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"; @@ -214,16 +214,19 @@ export function resolveModelServiceTier( /** * True when the tier should be sent on the wire as the provider's service-tier - * request field. OpenAI / OpenAI-Codex accept every {@link ServiceTier}; - * Google (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; - * Fireworks Serverless realizes only its Priority serving path. Anthropic is - * absent because it realizes `priority` via `speed: "fast"`. + * request field. `auto` is never forwarded — it is OpenAI's implicit default, so + * omitting `service_tier` is identical to requesting `auto`, and the Codex + * (ChatGPT OAuth) endpoint rejects an explicit `auto` outright. OpenAI / + * OpenAI-Codex accept every other {@link ServiceTier}; Google (Gemini API + + * Vertex) and OpenRouter accept `flex`/`priority`; Fireworks Serverless + * realizes only its Priority serving path. Anthropic is absent because it + * realizes `priority` via `speed: "fast"`. */ export function shouldSendServiceTier( serviceTier: ServiceTier | null | undefined, target: Provider | ServiceTierModel | undefined, ): boolean { - if (!serviceTier) return false; + if (!serviceTier || serviceTier === "auto") return false; const provider = typeof target === "string" ? target : target?.provider; if (provider === "openai" || provider === "openai-codex") return true; if (provider === "openrouter") { 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/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index a0b737a5c..0ab77276a 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import { create } from "@bufbuild/protobuf"; import { type BlockState, @@ -32,6 +32,11 @@ import { ReadResultSchema, ReadSuccessSchema, } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; +import { logger } from "@oh-my-pi/pi-utils"; + +afterEach(() => { + vi.restoreAllMocks(); +}); const cursorModel: Model<"cursor-agent"> = buildModel({ id: "cursor-composer-2.5", @@ -757,10 +762,55 @@ describe("Cursor history encoding", () => { ]; expect(() => buildCursorHistoryForTest(messages, undefined, "kimi-k3-high")).toThrow( - "start a new session instead of continuing history from anthropic/claude-4.6-opus-high", + "cannot continue history from a different model (anthropic/claude-4.6-opus-high)", ); }); + it("replays a same-model K3 turn missing thinking instead of bricking the session", () => { + const messages: Context["messages"] = [ + { role: "user", content: "Do a big multi-tool task", timestamp: 1 }, + cursorAssistant( + "kimi-k3-high", + [{ type: "toolCall", id: "call-read", name: "read", arguments: { path: "package.json" } }], + 2, + "toolUse", + ), + { + role: "toolResult", + toolCallId: "call-read", + toolName: "read", + content: [{ type: "text", text: "package contents" }], + isError: false, + timestamp: 3, + }, + cursorAssistant("kimi-k3-high", [{ type: "text", text: "Done." }], 4), + { role: "user", content: "Continue the same session", timestamp: 5 }, + ]; + + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const history = buildCursorHistoryForTest(messages, undefined, "kimi-k3-high"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("assistant turn(s) 1, 2"), { + model: "kimi-k3-high", + assistantTurns: [1, 2], + }); + buildCursorHistoryForTest(messages, undefined, "kimi-k3-high"); + expect(warnSpy).toHaveBeenCalledTimes(1); + + expect(history.rootPromptMessagesJson).toEqual([ + { role: "user", content: [{ type: "text", text: "Do a big multi-tool task" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-read", toolName: "read", args: { path: "package.json" } }], + }, + { + role: "tool", + id: "call-read", + content: [{ type: "tool-result", toolName: "read", toolCallId: "call-read", result: "package contents" }], + }, + { role: "assistant", content: [{ type: "text", text: "Done." }] }, + ]); + }); + it("keeps non-K3 Cursor thinking out of model-facing history", () => { const messages: Context["messages"] = [ { role: "user", content: "Inspect package.json", timestamp: 1 }, @@ -1088,6 +1138,28 @@ function newBlockState(): BlockState { }; } +describe("Cursor K3 completion warnings", () => { + it("warns when a K3 turn ends without thinking blocks", () => { + const output = cursorAssistantMessage(); + output.model = "kimi-k3-high"; + output.timestamp = 7516; + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + processInteractionUpdate( + { message: { case: "turnEnded", value: {} } }, + output, + new AssistantMessageEventStream(), + newBlockState(), + { sawTokenDelta: false }, + ); + + expect(warnSpy).toHaveBeenCalledWith( + "Cursor kimi-k3 turn completed without thinking blocks; persisted history will replay this turn without reasoning", + { model: "kimi-k3-high", messageTimestamp: 7516 }, + ); + }); +}); + describe("Cursor exec local-work tracking (issue #4593)", () => { it("marks the stream busy for the duration of a local exec handler", async () => { const output = cursorAssistantMessage(); 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..096398e4a 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", @@ -25,16 +25,22 @@ function abortedSignal(): AbortSignal { return controller.signal; } +interface CaptureOptions { + tools?: Tool[]; + reasoning?: Effort; + disableReasoning?: boolean; +} + async function capturePayload( model: Model<"openai-completions">, - tools?: Tool[], - reasoning: "high" | "max" = "high", + options: CaptureOptions = {}, ): Promise<Record<string, unknown>> { const { promise, resolve } = Promise.withResolvers<unknown>(); - streamOpenAICompletions(model, contextWithTools(tools), { + streamOpenAICompletions(model, contextWithTools(options.tools), { apiKey: "test-key", signal: abortedSignal(), - reasoning, + reasoning: options.disableReasoning ? undefined : (options.reasoning ?? Effort.High), + disableReasoning: options.disableReasoning, toolChoice: "auto", maxTokens: 123, onPayload: payload => resolve(payload), @@ -42,7 +48,7 @@ async function capturePayload( return (await promise) as Record<string, unknown>; } -function customDeepseekFlash(): Model<"openai-completions"> { +function customDeepseekFlash(legacyThinkingExtraBody = false): Model<"openai-completions"> { return buildModel({ ...getBundledModel("openai", "gpt-4o-mini"), api: "openai-completions", @@ -54,6 +60,7 @@ function customDeepseekFlash(): Model<"openai-completions"> { compat: { supportsReasoningEffort: true, reasoningEffortMap: { xhigh: "max" }, + ...(legacyThinkingExtraBody ? { extraBody: { thinking: { type: "enabled" } } } : {}), }, } as ModelSpec<"openai-completions">); } @@ -65,7 +72,9 @@ describe("issue #1207 — DeepSeek V4 keeps reasoning with tools", () => { expect(compat.supportsToolChoice).toBe(false); expect(compat.maxTokensField).toBe("max_tokens"); - expect(compat.extraBody).toEqual({ thinking: { type: "enabled" } }); + expect(compat.extraBody).toBeUndefined(); + expect(compat.reasoningDisableMode).toBe("zai-thinking-disabled"); + expect(compat.whenThinking?.extraBody).toEqual({ thinking: { type: "enabled" } }); // DeepSeek's reasoning_effort is the honest wire-exact high/max pair; // no synthetic lower tiers, no alias map. expect(model.thinking?.efforts).toEqual([Effort.High, Effort.Max]); @@ -93,6 +102,18 @@ describe("issue #1207 — DeepSeek V4 keeps reasoning with tools", () => { expect(body.max_completion_tokens).toBeUndefined(); }); + it("disables thinking for bundled and legacy cached model definitions", async () => { + const bundled = getBundledModel("deepseek", "deepseek-v4-flash") as Model<"openai-completions">; + const legacyCached = customDeepseekFlash(true); + + for (const model of [bundled, legacyCached]) { + const body = await capturePayload(model, { disableReasoning: true }); + expect(model.compat.extraBody).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.thinking).toEqual({ type: "disabled" }); + } + }); + it("does not mix Fireworks DeepSeek effort with the native thinking toggle", async () => { const model = getBundledModel("fireworks", "deepseek-v4-pro") as Model<"openai-completions">; const compat = model.compat; @@ -127,7 +148,7 @@ describe("issue #1207 — DeepSeek V4 keeps reasoning with tools", () => { paths: "(string | string[])?", }), }; - const body = await capturePayload(model, [unionTool]); + const body = await capturePayload(model, { tools: [unionTool] }); const tools = body.tools as Array<{ function: { parameters: Record<string, unknown> } }>; const properties = tools[0].function.parameters.properties as Record<string, Record<string, unknown>>; const branches = properties.paths.anyOf as Array<Record<string, unknown>>; 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<TApi extends "openai-responses" | "openai-codex-responses">( 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<string, string>?", + "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/service-tier-premium-requests.test.ts b/packages/ai/test/service-tier-premium-requests.test.ts index c0d0119cf..96fe0a3f8 100644 --- a/packages/ai/test/service-tier-premium-requests.test.ts +++ b/packages/ai/test/service-tier-premium-requests.test.ts @@ -78,14 +78,17 @@ describe("resolveModelServiceTier", () => { }); describe("shouldSendServiceTier", () => { - it("sends every tier on the OpenAI family and supported tiers elsewhere", () => { + it("sends every explicit tier on the OpenAI family, omits auto, supported tiers elsewhere", () => { for (const p of ["openai", "openai-codex"] as const) { expect(shouldSendServiceTier("flex", p)).toBe(true); expect(shouldSendServiceTier("scale", p)).toBe(true); expect(shouldSendServiceTier("priority", p)).toBe(true); expect(shouldSendServiceTier("default", p)).toBe(true); - expect(shouldSendServiceTier("auto", p)).toBe(true); + // `auto` is OpenAI's implicit default and the Codex endpoint rejects it — never sent. + expect(shouldSendServiceTier("auto", p)).toBe(false); } + expect(shouldSendServiceTier("auto", codex)).toBe(false); + expect(shouldSendServiceTier("auto", customOpenAI)).toBe(false); expect(shouldSendServiceTier("flex", "openrouter")).toBe(true); expect(shouldSendServiceTier("default", "openrouter")).toBe(false); expect(shouldSendServiceTier("priority", customCodex)).toBe(true); 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..cab9afb53 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.2.7] - 2026-08-03 + +### Fixed + +- Fixed an issue where setting `thinking-level: off` failed to disable reasoning on direct DeepSeek V4 requests. + ## [17.2.6] - 2026-08-03 ### Added diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 26de647de..037a0131f 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "17.2.6", + "version": "17.2.8", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -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/compat/openai.ts b/packages/catalog/src/compat/openai.ts index dc29945f7..c7f97b4e1 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -583,7 +583,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv wireModelIdMode, isVercelGatewayHost: isVercelGateway, supportsStrictMode: detectStrictModeSupport(provider, baseUrl), - extraBody: isDirectDeepseekReasoning ? { thinking: { type: "enabled" } } : undefined, + extraBody: undefined, toolStrictMode: isCerebras ? "all_strict" : "mixed", // Kimi-family ids trigger MFJS on any host, not just native base URLs: // proxies (OpenRouter, custom gateways) forward `tools.function.parameters` @@ -604,10 +604,24 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv }; applyCompatOverrides(compat, spec.compat); + const deepseekThinking = compat.extraBody?.thinking; + if ( + isDirectDeepseekReasoning && + typeof deepseekThinking === "object" && + deepseekThinking !== null && + "type" in deepseekThinking && + deepseekThinking.type === "enabled" + ) { + const extraBody = { ...compat.extraBody }; + delete extraBody.thinking; + compat.extraBody = Object.keys(extraBody).length > 0 ? extraBody : undefined; + } if (spec.compat?.reasoningDisableMode === undefined) { compat.reasoningDisableMode = requiresEnabledThinking ? "omit" - : resolveReasoningDisableMode(compat.thinkingFormat); + : isDirectDeepseekReasoning + ? "zai-thinking-disabled" + : resolveReasoningDisableMode(compat.thinkingFormat); } if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) { compat.omitReasoningEffort = true; @@ -615,7 +629,12 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv mergeModelReasoningEffortMap(compat, spec.id, isMimoReasoningEffortModel); const whenThinkingPolicy = - spec.compat?.whenThinking ?? (isOpenCodeProvider && spec.reasoning ? OPENCODE_WHEN_THINKING : undefined); + spec.compat?.whenThinking ?? + (isDirectDeepseekReasoning + ? { extraBody: { ...compat.extraBody, thinking: { type: "enabled" } } } + : isOpenCodeProvider && spec.reasoning + ? OPENCODE_WHEN_THINKING + : undefined); if (whenThinkingPolicy) { const variant: ResolvedOpenAICompat = { ...compat }; applyCompatOverrides(variant, whenThinkingPolicy); 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 715da230d..28d2eaeaf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,21 @@ ### Fixed - Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) +## [17.2.7] - 2026-08-03 + +### Changed + +- Replaced arktype with @oh-my-pi/omptype for tool parameter and config schemas, significantly improving startup performance with ~100x faster schema construction. Config schema errors are now reported via OmpErrors using the same path/problem structure. + +### Fixed + +- Fixed an issue where custom, extension, or hook tool wrappers stripped schema methods off parameters, causing wire-schema detection failures and status-line token estimator crashes. +- Fixed a bug where agent() calls in evaluation cells ignored turn cancellation and continued running indefinitely. +- Fixed the built-in tail command to exit silently with code 141 (SIGPIPE) instead of failing with a "Broken pipe" error when a downstream pipeline reader exits early. +- Fixed the in-process ps shell builtin to support common procps/BSD format specifiers, including tpgid, pri, flags, real/effective user/group columns, wchan, fault counters, sz, and the STAT + foreground flag. +- Fixed install.sh falsely reporting success on musl-based systems (such as Alpine Linux) when the binary fails to start; the installer now smoke-tests the binary, exits non-zero on failure, and provides remediation steps. +- Fixed Codex config.toml discovery incorrectly importing MCP servers that are configured with enabled = false. +- Fixed bash.patterns allow rules rejecting valid commands when quoted arguments contained shell metacharacters (such as Cargo benchmark regex filters). ## [17.2.6] - 2026-08-03 @@ -2262,7 +2277,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-<platform>-<arch>` 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-<platform>-<arch>` 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/<binary-name>` 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..03ac0fabc 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "17.2.6", + "version": "17.2.8", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -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<BundledPiEntry[]> { } } - 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<T> implements IConfigFile<T> { } 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/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index cd993c989..d5328371f 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -87,19 +87,13 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> ]); const items: MCPServer[] = []; - if (userConfig) { - const servers = extractMCPServersFromToml(userConfig, path.dirname(userConfigPath)); - for (const [name, config] of Object.entries(servers)) { - items.push({ - name, - ...config, - _source: createSourceMeta(PROVIDER_ID, userConfigPath, "user"), - }); - } - } + // Capability dedupe is first-wins, including suppressed items claiming their + // key. Load project entries first so a project `enabled = false` keeps a + // same-named user server disabled. if (projectConfig) { const servers = extractMCPServersFromToml(projectConfig, path.dirname(projectConfigPath)); - for (const [name, config] of Object.entries(servers)) { + for (const name in servers) { + const config = servers[name]; items.push({ name, ...config, @@ -107,6 +101,17 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> }); } } + if (userConfig) { + const servers = extractMCPServersFromToml(userConfig, path.dirname(userConfigPath)); + for (const name in servers) { + const config = servers[name]; + items.push({ + name, + ...config, + _source: createSourceMeta(PROVIDER_ID, userConfigPath, "user"), + }); + } + } return { items, warnings }; } @@ -125,6 +130,7 @@ async function loadTomlConfig(_ctx: LoadContext, path: string): Promise<Record<s /** Codex MCP server config format (from config.toml) */ interface CodexMCPConfig { + enabled?: boolean; command?: string; args?: string[]; env?: Record<string, string>; @@ -152,13 +158,20 @@ function extractMCPServersFromToml( const codexServers = toml.mcp_servers as Record<string, CodexMCPConfig>; const result: Record<string, Partial<MCPServer>> = {}; - for (const [name, config] of Object.entries(codexServers)) { + for (const name in codexServers) { + const config = codexServers[name]; // Root relative cwd/command against the Codex config directory. Codex // spawns the process with the resolved cwd, so a relative command is // resolved by the OS from there — pass "cwd" so e.g. cwd="server", // command="./bin/mcp" resolves to <configDir>/server/bin/mcp. const rooted = resolvePluginStdioPaths({ command: config.command, cwd: config.cwd }, configDir, "cwd"); const server: Partial<MCPServer> = { + // Carry `enabled: false` through rather than dropping the entry: the + // central MCP loader (`loadAllMCPConfigs`) suppresses disabled servers + // so they still claim their dedupe key (keeping a same-named, + // lower-priority source disabled) and remain overridable via the user + // force-enable allowlist. Dropping here would defeat both. + ...(config.enabled === false && { enabled: false }), ...(rooted.command !== undefined && { command: rooted.command }), args: config.args, url: config.url, 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 92936b15a..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, @@ -155,7 +155,9 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption ...(isolation ? { isolation } : {}), ...(parsed.handle ? { retainArtifacts: true } : {}), keepAlive: false, - maxRuntimeMs: 0, + // `maxRuntimeMs` is intentionally omitted: the executor then inherits + // `task.maxRuntimeMs`, matching the task tool. Pinning it to 0 here + // silently overrode the user's wall-clock cap for eval fan-outs. shareEvalSession: false, ...(options.signal !== undefined ? { signal: options.signal } : {}), ...(options.emitStatus diff --git a/packages/coding-agent/src/eval/bridge-timeout.ts b/packages/coding-agent/src/eval/bridge-timeout.ts index cdb714eb6..d293354d0 100644 --- a/packages/coding-agent/src/eval/bridge-timeout.ts +++ b/packages/coding-agent/src/eval/bridge-timeout.ts @@ -29,8 +29,11 @@ export function isEvalTimeoutControlEvent(event: JsStatusEvent): boolean { /** Optional behavior for a timeout pause around a host bridge call. */ export interface BridgeTimeoutPauseOptions { /** - * Marks the pause as an `agent()` call whose already-started work must finish - * before an external eval abort reaches the kernel. + * Holds an external eval abort back from the *kernel* until this bridge call + * settles, so the runtime is never torn down mid-phase (`agent()` isolation + * worktree setup and merge/cherry-pick). It does not shield the delegated + * work itself: the bridge hands subagents the caller's real signal, so a + * turn cancel still stops them immediately. */ deferExternalAbort?: boolean; } 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<string,unknown>", + "schema?": { "[string]": "unknown" }, }); export interface EvalCompletionBridgeOptions { diff --git a/packages/coding-agent/src/eval/executor-base.ts b/packages/coding-agent/src/eval/executor-base.ts index eb12e287d..c89bac7bd 100644 --- a/packages/coding-agent/src/eval/executor-base.ts +++ b/packages/coding-agent/src/eval/executor-base.ts @@ -158,6 +158,15 @@ export async function waitForPromiseWithCancellation<T>( return await resultPromise; } +/** + * Derived abort signal for the kernel, holding back an external abort while a + * bridge call marked `deferExternalAbort` is in flight. + * + * Scope is deliberately narrow: only `kernel.execute` consumes + * {@link BridgeAbortShield.signal}. Work dispatched through the tool bridge + * keeps the caller's real signal so a turn cancel reaches spawned subagents + * immediately — deferral protects the runtime, never the delegated work. + */ interface BridgeAbortShield { signal: AbortSignal | undefined; abortRequested: boolean; @@ -428,11 +437,21 @@ export async function executeWithKernelBase< const emitStatus: (event: JsStatusEvent) => void = options?.emitStatus ?? (event => collectDisplay({ type: "status", event })); const runId = `${runIdPrefix}-${crypto.randomUUID()}`; + // Two aborts cross the bridge, and conflating them is what let a cancelled + // turn keep working. Delegated work (above all the subagents `agent()` + // spawns) gets the caller's real signal so it dies with the turn — shielding + // it here made Python/Ruby/Julia fan-outs outlive a cancel indefinitely, + // while JS cells, which never route through this shield, stopped fine. The + // shielded signal only governs how long the host waits on a call, holding + // the cell open across a critical phase (isolation worktree setup, + // merge/cherry-pick) so a cancel can't settle it on top of a half-applied + // git operation. const unregisterBridge = options?.toolSession && options?.bridgeSessionId ? registerPyToolBridge(options.bridgeSessionId, runId, { toolSession: options.toolSession, - signal: abortShield.signal, + signal: options.signal, + shieldedSignal: abortShield.signal, emitStatus, abortRequested: () => { return abortShield.abortRequested; diff --git a/packages/coding-agent/src/eval/js/context-manager.ts b/packages/coding-agent/src/eval/js/context-manager.ts index 449754b08..14341ee24 100644 --- a/packages/coding-agent/src/eval/js/context-manager.ts +++ b/packages/coding-agent/src/eval/js/context-manager.ts @@ -8,6 +8,7 @@ import { import type { ToolSession } from "../../tools"; import { ToolAbortError, ToolError } from "../../tools/tool-errors"; import { safeSend as safeSendIpc } from "../../utils/ipc"; +import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout"; import { attachSessionOwner, resolveOwnerScopedSessionKey, type SessionOwners } from "../executor-base"; import { shouldDetachKernel } from "../py/spawn-options"; import { callSessionTool, type JsStatusEvent } from "./tool-bridge"; @@ -48,6 +49,26 @@ interface PendingRun { resolve(value: { value: unknown }): void; reject(error: Error): void; toolCalls: Map<string, AbortController>; + /** + * Host calls currently inside a `deferExternalAbort` phase — `agent()` + * isolation worktree setup and merge/cherry-pick, which ignore their abort + * once started. Settling the run while one is live would return the cell on + * top of a git operation still rewriting the repo, so the abort path drains + * this first. Mirrors the Python bridge's shielded-signal contract. + */ + deferDepth: number; + /** Resolves once {@link deferDepth} falls back to zero. */ + deferDrained?: PromiseWithResolvers<void>; + /** Set once the turn was cancelled; blocks new bridge calls during the drain. */ + aborted: boolean; + /** + * A worker `result` withheld because the cell still has bridge calls in + * flight. `#runOne` reports a finished run without awaiting its pending + * tools, so a floated or caught `agent()` would otherwise settle the run — + * tearing down the abort listener — while the subagent kept going with + * nothing left able to cancel it. Delivered once the last call drains. + */ + heldResult?: Extract<WorkerOutbound, { type: "result" }>; settled: boolean; } @@ -267,6 +288,8 @@ async function runOnce( resolve, reject, toolCalls: new Map(), + deferDepth: 0, + aborted: false, settled: false, }; session.pending.set(runId, pending); @@ -274,9 +297,21 @@ async function runOnce( const onAbort = (): void => { const reason = options.runState.signal?.reason; const abortError = reasonToError(reason, "Execution aborted"); - // Cancel any in-flight tool calls first. + // Stop delegated work at once — this is what kills spawned subagents — + // and refuse further bridge calls so the drain below stays bounded to + // phases that had already started. + pending.aborted = true; for (const ctrl of pending.toolCalls.values()) ctrl.abort(abortError); - // Hard-kill the worker — only way to interrupt synchronous user code. + // A critical host phase ignores its abort once started (isolation + // worktree setup, merge/cherry-pick). Killing the worker now would + // settle the cell on top of a git operation still in progress, so wait + // for it. Hard-kill is still the only way to interrupt synchronous user + // code, hence it stays the terminal step either way. + const drained = pending.deferDepth > 0 ? pending.deferDrained?.promise : undefined; + if (drained) { + void drained.then(() => killSessionFor(session, abortError, { force: true })); + return; + } void killSessionFor(session, abortError, { force: true }); }; @@ -458,6 +493,25 @@ function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void { } } +/** + * Maintain {@link PendingRun.deferDepth} from the bridge's pause/resume status + * events so an abort can wait out a critical `agent()` phase instead of + * settling the cell over a half-applied merge. + */ +function trackDeferPhase(pending: PendingRun, event: JsStatusEvent): void { + if (event.deferExternalAbort !== true) return; + if (event.op === EVAL_TIMEOUT_PAUSE_OP) { + pending.deferDepth++; + pending.deferDrained ??= Promise.withResolvers<void>(); + return; + } + if (event.op !== EVAL_TIMEOUT_RESUME_OP || pending.deferDepth === 0) return; + pending.deferDepth--; + if (pending.deferDepth > 0) return; + pending.deferDrained?.resolve(); + pending.deferDrained = undefined; +} + async function handleToolCall(session: JsSession, msg: Extract<WorkerOutbound, { type: "tool-call" }>): Promise<void> { const pending = session.pending.get(msg.runId); if (!pending) { @@ -468,26 +522,42 @@ async function handleToolCall(session: JsSession, msg: Extract<WorkerOutbound, { }); return; } + if (pending.aborted) { + safeSend(session, { + type: "tool-reply", + id: msg.id, + reply: { ok: false, error: { message: "Run was interrupted" } }, + }); + return; + } const ctrl = new AbortController(); pending.toolCalls.set(msg.id, ctrl); try { const value = await callSessionTool(msg.name, msg.args, { session: pending.toolSession, signal: ctrl.signal, - emitStatus: (event: JsStatusEvent) => pending.runState.onDisplay?.({ type: "status", event }), + emitStatus: (event: JsStatusEvent) => { + trackDeferPhase(pending, event); + pending.runState.onDisplay?.({ type: "status", event }); + }, }); safeSend(session, { type: "tool-reply", id: msg.id, reply: { ok: true, value } }); } catch (error) { safeSend(session, { type: "tool-reply", id: msg.id, reply: { ok: false, error: toErrorPayload(error) } }); } finally { pending.toolCalls.delete(msg.id); + // Last call of a run whose worker result was withheld: settle it now. + const held = pending.heldResult; + if (held && !pending.settled && !pending.aborted && pending.toolCalls.size === 0) { + finishPending(pending, held); + } } } -function settlePending(session: JsSession, msg: Extract<WorkerOutbound, { type: "result" }>): void { - const pending = session.pending.get(msg.runId); - if (!pending || pending.settled) return; +/** Deliver a worker `result` to the waiting {@link runOnce}. */ +function finishPending(pending: PendingRun, msg: Extract<WorkerOutbound, { type: "result" }>): void { pending.settled = true; + pending.heldResult = undefined; if (msg.ok) { pending.resolve({ value: undefined }); return; @@ -495,6 +565,24 @@ function settlePending(session: JsSession, msg: Extract<WorkerOutbound, { type: pending.reject(errorFromPayload(msg.error)); } +function settlePending(session: JsSession, msg: Extract<WorkerOutbound, { type: "result" }>): void { + const pending = session.pending.get(msg.runId); + if (!pending || pending.settled) return; + // Once the turn is cancelled the scheduled kill is the sole settler, so a + // late worker result can't cut the abort drain short. + if (pending.aborted) return; + // A cell owns every bridge call it starts. The worker finishes a run without + // awaiting its outstanding tool calls, so `agent(...)` that is floated or + // caught would settle the run here — `runOnce` then drops the abort listener + // and the pending entry, leaving the subagent running with nothing able to + // cancel it. Hold the result until the last call drains. + if (pending.toolCalls.size > 0) { + pending.heldResult = msg; + return; + } + finishPending(pending, msg); +} + async function killSessionFor(session: JsSession, error: Error, options: { force: boolean }): Promise<void> { if (sessions.get(session.sessionKey) === session) { sessions.delete(session.sessionKey); diff --git a/packages/coding-agent/src/eval/py/tool-bridge.ts b/packages/coding-agent/src/eval/py/tool-bridge.ts index f593c7cda..71d61a81b 100644 --- a/packages/coding-agent/src/eval/py/tool-bridge.ts +++ b/packages/coding-agent/src/eval/py/tool-bridge.ts @@ -13,7 +13,19 @@ import { callSessionTool, type JsStatusEvent } from "../js/tool-bridge"; export interface PyToolBridgeEntry { toolSession: ToolSession; + /** + * Turn-cancel handed to the tool implementation. Raw and never deferred, so + * delegated work — above all the subagents `agent()` spawns — stops at once. + */ signal?: AbortSignal; + /** + * Kernel-side abort, held back while a critical `agent()` phase (isolation + * worktree setup, merge/cherry-pick) is in flight. Decides only when the host + * may stop waiting on a call and let the kernel unwind; it is never given to + * a tool. Keeping these separate is what stops a cancel from settling the + * cell on top of a still-running, abort-insensitive merge. + */ + shieldedSignal?: AbortSignal; emitStatus?: (event: JsStatusEvent) => void; abortRequested?: () => boolean; } @@ -32,16 +44,24 @@ const registrations = new Map<string, PyToolBridgeEntry>(); let serverPromise: Promise<BridgeServer> | null = null; /** - * Forward a bridge call to {@link callSessionTool} while respecting eval abort - * shielding. + * Forward a bridge call to {@link callSessionTool}, failing fast once the cell + * has been interrupted. * * Python invokes this bridge with blocking `urllib` requests from worker threads - * (each `agent()` / `tool.*` call). The base executor defers the registered - * signal while a bridge call is already paused so in-flight subagents can finish - * and persist output instead of being orphaned. Once an abort has been requested, - * later bridge calls are rejected before starting; once the shielded signal - * finally aborts, this handler still resolves the HTTP request promptly so the - * kernel can unwind without being hard-killed. + * (each `agent()` / `tool.*` call). Two different aborts meet here: + * + * - {@link PyToolBridgeEntry.signal} goes to the tool, so a turn cancel tears + * down delegated work — subagents included — instead of leaving it running + * past the cell. + * - {@link PyToolBridgeEntry.shieldedSignal} decides when we may stop waiting. + * It is deferred across a critical `agent()` phase, so a cancel landing + * mid-merge cannot return early and let the cell settle while an + * abort-insensitive cherry-pick is still rewriting the repo. + * + * Calls arriving after an abort are rejected before starting. Otherwise the + * usual path is that the tool observes its own abort and rejects; the race only + * matters for tools that ignore the signal, keeping the kernel unwinding + * promptly instead of being hard-killed. */ async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: PyToolBridgeEntry): Promise<unknown> { if (entry.abortRequested?.()) { @@ -52,7 +72,7 @@ async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: signal: entry.signal, emitStatus: entry.emitStatus, }); - const signal = entry.signal; + const signal = entry.shieldedSignal ?? entry.signal; if (!signal) return await call; if (signal.aborted) { void call.catch(() => {}); 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<T extends string> { 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<T> { + success: true; + data: T; +} + +interface SafeParseFailure { + success: false; + error: ValidationFailure; +} + +type LegacyUnsafeSchema<T> = Record<string, unknown> & + TUnsafe<T> & { + __validator(data: unknown): T | ValidationFailure; + safeParse(input: unknown): SafeParseSuccess<T> | SafeParseFailure; + }; + +function defineHidden(target: object, key: PropertyKey, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + configurable: true, + }); +} + +function unsafe<T = unknown>(jsonSchema: Record<string, unknown> = {}): LegacyUnsafeSchema<T> { + const schema = { ...jsonSchema } as LegacyUnsafeSchema<T>; + 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<T> | 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<string, unknown>, 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<typeof OmpType.Object>[0], opts); + + const propertySchemas: Record<string, unknown> = {}; + const required: string[] = []; + for (const key in properties) { + const property = properties[key]; + propertySchemas[key] = + typeof property === "function" && "toJsonSchema" in property + ? (property as { toJsonSchema(): Record<string, unknown> }).toJsonSchema() + : property; + required.push(key); + } + const document: Record<string, unknown> = { 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 extends ArkSchema> = 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<string, ArkSchema> = Record<string, ArkSchema>> = 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<string, unknown>; - properties?: Record<string, ArkSchema>; - 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<string, unknown>; - __optional?: true; - __properties?: Record<string, ArkSchema>; - __additionalProperties?: boolean | ArkSchema; - __infer?: unknown; - __inner?: ArkSchema; - safeParse(input: unknown): SafeParseSuccess | SafeParseFailure; - toJSON(): Record<string, unknown>; - [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<string, unknown> = {}; - for (const key in value) { - result[key] = schemaJsonValue((value as Record<string, unknown>)[key]); - } - return result; -} - -function jsonSchemaOf(schema: ArkSchema): Record<string, unknown> { - const result: Record<string, unknown> = {}; - for (const key in schema) result[key] = schemaJsonValue(schema[key]); - return result; -} - -function createArkSchema( - validator: (data: unknown) => unknown, - jsonSchema: Record<string, unknown> = {}, - 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<string, unknown>, - 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<string, unknown>): 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<string, unknown> = {}; - 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<string, ArkSchema>, opts?: ObjectOpts): (data: unknown) => unknown { - return (data: unknown) => { - if (!data || typeof data !== "object") return validationFailure("Expected object"); - const obj = data as Record<string, unknown>; - const result: Record<string, unknown> = {}; - - const keys = new Set<string>(); - 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<string, unknown>; - const result: Record<string, unknown> = {}; - 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<string | number | boolean> { - const unique: Array<string | number | boolean> = []; - for (const value of values) { - if (!unique.some(existing => existing === value)) unique.push(value); - } - return unique; -} - -function requiredKeys(properties: Record<string, ArkSchema>): 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<string, ArkSchema>, opts?: ObjectOpts): Record<string, unknown> { - const propertySchemas: Record<string, unknown> = {}; - for (const key in properties) propertySchemas[key] = jsonSchemaOf(properties[key]); - const schema: Record<string, unknown> = { 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<V extends string | number | boolean>(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<T extends readonly ArkSchema[]>(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<T extends Record<string, string | number> | 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<E extends ArkSchema>(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<P extends Record<string, ArkSchema>>(properties: P, opts?: ObjectOpts): ArkSchema { - const props = properties as Record<string, ArkSchema>; - 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<string, unknown> { - const valueJson = jsonSchemaOf(valueSchema); - const keys = literalRecordKeys(keySchema); - if (keys) { - const properties: Record<string, unknown> = {}; - for (const key of keys) properties[key] = valueJson; - return { type: "object", properties, required: keys, additionalProperties: false }; - } - const json: Record<string, unknown> = { type: "object", additionalProperties: valueJson }; - const keyJson = jsonSchemaOf(keySchema); - if (Object.keys(keyJson).length > 0) json.propertyNames = keyJson; - return json; -} - -function tRecord<K extends ArkSchema, V extends ArkSchema>(key: K, value: V, opts?: Meta): ArkSchema { - const validator = createRecordValidator(key, value); - return applyMeta(createArkSchema(validator, createRecordJson(key, value)), opts); -} - -function tOptional<E extends ArkSchema>(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<E extends ArkSchema>(schema: E, opts?: Meta): ArkSchema { - const validator = createNullableValidator(schema); - return applyMeta(createArkSchema(validator, { anyOf: [jsonSchemaOf(schema), { type: "null" }] }), opts); -} - -function tReadonly<E extends ArkSchema>(schema: E): ArkSchema { - // TypeBox's `Type.Readonly` is purely a marker; runtime validation is identical. - return schema; -} - -function tPartial<_P extends Record<string, ArkSchema>>(obj: ArkSchema): ArkSchema { - if (obj.__properties) { - const properties: Record<string, ArkSchema> = {}; - 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<string, ArkSchema>>(obj: ArkSchema): ArkSchema { - if (obj.__properties) { - const properties: Record<string, ArkSchema> = {}; - 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<string, unknown>; - const required: string[] = []; - for (const key in properties) { - required.push(key); - } - metadata.required = required; - } - return createArkSchema(obj.__validator, metadata); -} - -function tPick<P extends Record<string, ArkSchema>, K extends keyof P>(obj: ArkSchema, keys: readonly K[]): ArkSchema { - const keySet = new Set([...keys].map(String)); - if (obj.__properties) { - const properties: Record<string, ArkSchema> = {}; - 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<string, unknown> = {}; - const obj_data = data as Record<string, unknown>; - - 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<string, unknown>; - const filteredProps: Record<string, unknown> = {}; - 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<P extends Record<string, ArkSchema>, K extends keyof P>(obj: ArkSchema, keys: readonly K[]): ArkSchema { - const keySet = new Set([...keys].map(String)); - if (obj.__properties) { - const properties: Record<string, ArkSchema> = {}; - 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<string, unknown> = {}; - const obj_data = data as Record<string, unknown>; - - 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<string, unknown>; - const filteredProps: Record<string, unknown> = {}; - 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<string, ArkSchema> = {}; - 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<string, unknown>; - const obj_data = data as Record<string, unknown>; - - 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<string, unknown>)[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<T = unknown>(jsonSchema: Record<string, unknown> = {}): TUnsafe<T> { - // 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<string, string | number>", + "vars?": { "[string]": "string | number" }, colors: themeColorsSchema, "export?": { "pageBg?": "string | number", @@ -1127,7 +1127,7 @@ const themeJsonSchema = type({ }, "symbols?": { "preset?": "'unicode' | 'nerd' | 'ascii'", - "overrides?": "Record<string, string>", + "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/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index db1d56f12..51a2e6fc8 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -389,7 +389,7 @@ export interface FreshSessionResult { closedProviderSessions: number; } -/** Outcome of an in-place `/reset` conversation-context reset. */ +/** Outcome of an in-place `/clear` conversation-context reset. */ export interface ResetSessionContextResult { /** Number of live messages dropped from the model's context. */ droppedCount: number; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2f6523b18..b86ad9bdb 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -3898,8 +3898,7 @@ export class AgentSession { // Record a durable boundary on the persisted branch. The collapsed live // transcript and the model-context rebuild start emission after the latest - // boundary, so a rebuild across a `/reset` (theme change, focus attach, - // /shake, resume) does not resurrect the pre-reset conversation. The + // boundary, so a rebuild across a `/clear` (theme change, focus attach, // on-disk record and the plain `transcript:true` export path keep the full // pre-reset history. this.sessionManager.appendResetBoundary(); diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index 49234c5c4..de08caef2 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -284,7 +284,7 @@ export function buildSessionContext( const injectedTtsrRules = Array.from(injectedTtsrRulesSet); - // Index on the path of the latest `/reset` boundary, or -1 when none. The + // Index on the path of the latest `/clear` boundary, or -1 when none. The // collapsed live transcript and the model-context rebuild start emission // after it (see the emission branch below); the full-history export path // ignores it. @@ -389,12 +389,12 @@ export function buildSessionContext( resetBoundaryIdx >= 0 && resetBoundaryIdx > (compaction ? path.findIndex(e => e.type === "compaction" && e.id === compaction.id) : -1) ) { - // A `/reset` boundary durably starts emission after it — for BOTH the + // A `/clear` boundary durably starts emission after it — for BOTH the // collapsed live transcript AND the model context (non-transcript) rebuild // that feeds agent.replaceMessages (resume, /shake, reload, image drop). // Without honoring it here, those model-context rebuilds walk the full // persisted branch and put the pre-reset turns back into the LLM context - // even though `/reset` reported it empty. The full-history export path + // even though `/clear` reported it empty. The full-history export path // (`transcript && !collapseCompactedHistory`) is handled by the first // branch above and left untouched, so on-disk history stays recoverable. // When a compaction and a reset boundary interact, the later one on the diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index eb99d9c61..bd28e4332 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -122,7 +122,7 @@ export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase { } /** - * Pure marker entry recorded by `/reset` (resetSessionContext). It carries no + * Pure marker entry recorded by `/clear` (resetSessionContext). It carries no * payload — its presence on the branch is a durable boundary the collapsed * live transcript and the model-context rebuild start emission after, so a * rebuild (theme change, focus attach, /shake, resume) does not resurrect the diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 86ed58785..6b2d7def5 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -2094,7 +2094,7 @@ export class SessionManager { } /** - * Append the durable conversation boundary recorded by `/reset`. The + * Append the durable conversation boundary recorded by `/clear`. The * collapsed live transcript and the model-context rebuild start after the * latest one, while the full history stays on disk (the plain * `transcript:true` export walks it unchanged). diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 6b753afb0..7a95a7f32 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1691,7 +1691,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [ }, { name: "new", - aliases: ["clear"], description: "Start a new session", handleTui: async (_command, runtime) => { runtime.ctx.editor.setText(""); @@ -1720,10 +1719,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [ }, }, { - name: "reset", - description: "Reset the conversation context in place, keeping the session", + name: "clear", + description: "Clear the conversation context in place, keeping the session", getTuiAutocompleteDescription: runtime => - runtime.ctx.session.isStreaming ? "Reset: unavailable while streaming" : "Reset: drop context, keep session", + runtime.ctx.session.isStreaming ? "Clear: unavailable while streaming" : "Clear: drop context, keep session", handleTui: async (_command, runtime) => { runtime.ctx.editor.setText(""); await runtime.ctx.handleResetContextCommand(); 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..9a4207a2b 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"; @@ -58,7 +58,65 @@ export const BASH_DEFAULT_PREVIEW_LINES = DEFAULT_TERMINAL_PREVIEW_LINES; const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000; -const BASH_APPROVAL_SHELL_CONTROL_RE = /[\n\r;&|<>`$()]/u; +const BASH_APPROVAL_SHELL_CONTROL_CHARS: Record<string, true> = { + "\n": true, + "\r": true, + ";": true, + "&": true, + "|": true, + "<": true, + ">": true, + "`": true, + $: true, + "(": true, + ")": true, +}; +const BASH_APPROVAL_REINTERPRETED_ARGUMENT_RE = /(?:^|[ \t])(?:-[^-]*[ce]|--(?:command|eval))(?:[= \t]|$)/u; + +function hasBashApprovalShellControl(command: string): boolean { + let quote: "'" | '"' | undefined; + let hasReinterpretableShellControl = false; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote === "'") { + if (ch === "'") { + quote = undefined; + } else if (Object.hasOwn(BASH_APPROVAL_SHELL_CONTROL_CHARS, ch)) { + hasReinterpretableShellControl = true; + } + continue; + } + if (ch === "\\") { + const escaped = command[i + 1]; + if (escaped && Object.hasOwn(BASH_APPROVAL_SHELL_CONTROL_CHARS, escaped)) { + hasReinterpretableShellControl = true; + } + i++; + continue; + } + if (quote === '"') { + if (ch === '"') { + quote = undefined; + continue; + } + // Expansion is active inside double quotes even in the original line. + if (ch === "`" || ch === "$") return true; + // Other control characters are literal here but become executable if a + // `-c`/`-e` option reinterprets the argument through another shell. + if (Object.hasOwn(BASH_APPROVAL_SHELL_CONTROL_CHARS, ch)) hasReinterpretableShellControl = true; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (Object.hasOwn(BASH_APPROVAL_SHELL_CONTROL_CHARS, ch)) return true; + } + // Options such as `git -c alias.x='!...'` and `sh -c "..."` reinterpret + // otherwise literal quoted or escaped arguments as executable code. + return hasReinterpretableShellControl && BASH_APPROVAL_REINTERPRETED_ARGUMENT_RE.test(command); +} + const BASH_PATTERN_APPROVAL_VALUES = new Set(["allow", "deny", "prompt"]); /** @@ -218,7 +276,7 @@ function commandSegmentMatchesBashApprovalPattern(command: string, pattern: stri // `prompt` fire on any matching segment so they mean what they appear to. function bashApprovalRuleMatches(command: string, rule: BashApprovalPatternRule): boolean { if (rule.approval === "allow") { - if (BASH_APPROVAL_SHELL_CONTROL_RE.test(command)) return false; + if (hasBashApprovalShellControl(command)) return false; return commandMatchesBashApprovalPattern(command, rule.match); } return commandSegmentMatchesBashApprovalPattern(command, rule.match); 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<Record<SettingPath, unknown>> = {}, 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> = {}): ToolSession { diff --git a/packages/coding-agent/test/discovery/codex-mcp-cwd.test.ts b/packages/coding-agent/test/discovery/codex-mcp-cwd.test.ts index 15ab602ab..06aeef700 100644 --- a/packages/coding-agent/test/discovery/codex-mcp-cwd.test.ts +++ b/packages/coding-agent/test/discovery/codex-mcp-cwd.test.ts @@ -1,5 +1,5 @@ /** - * Regression tests for #5561. + * Regression tests for Codex MCP config discovery (#5561, #7538). * * The Codex `config.toml` MCP importer in `packages/coding-agent/src/discovery/codex.ts` * used to copy only `command`/`args`/`url` into the returned `MCPServer`, dropping @@ -48,6 +48,59 @@ async function loadCodexServers(): Promise<MCPServer[]> { return result.items; } +test("disabled Codex MCP servers survive discovery tagged enabled: false (#7538)", async () => { + const codexDir = path.join(tempHome, ".codex"); + await fs.writeFile( + path.join(codexDir, "config.toml"), + [ + "[mcp_servers.computer-use]", + 'command = "./Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient"', + "enabled = false", + "", + "[mcp_servers.context7]", + 'command = "npx"', + "enabled = true", + "", + ].join("\n"), + ); + + const servers = await loadCodexServers(); + const cu = servers.find(server => server.name === "computer-use"); + const context7 = servers.find(server => server.name === "context7"); + + // The disabled entry is NOT dropped: it stays so loadAllMCPConfigs' suppress + // path can claim its dedupe key (keeping a same-named, lower-priority source + // disabled) and honor the user force-enable allowlist. `enabled: true` is the + // default, so it is not persisted onto the enabled sibling. + expect(cu?.enabled).toBe(false); + expect(context7?.command).toBe("npx"); + expect(context7?.enabled).toBeUndefined(); +}); + +test("project Codex disable suppresses a same-named user server (#7538)", async () => { + const userCodexDir = path.join(tempHome, ".codex"); + const projectCodexDir = path.join(tempCwd, ".codex"); + await fs.mkdir(projectCodexDir, { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(userCodexDir, "config.toml"), + ["[mcp_servers.shared]", 'command = "user-server"', ""].join("\n"), + ), + fs.writeFile( + path.join(projectCodexDir, "config.toml"), + ["[mcp_servers.shared]", 'command = "project-server"', "enabled = false", ""].join("\n"), + ), + ]); + + const result = await loadCapability<MCPServer>(mcpCapability.id, { + cwd: tempCwd, + providers: ["codex"], + suppress: server => server.enabled === false, + }); + + expect(result.items.find(server => server.name === "shared")).toBeUndefined(); +}); + test("relative path-like command and cwd resolve against the Codex config directory (#5561)", async () => { const codexDir = path.join(tempHome, ".codex"); await fs.writeFile( 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/eval/agent-bridge-policy.test.ts b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts index 65f27e474..fb290ced8 100644 --- a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts @@ -672,7 +672,7 @@ describe("agent() through eval runtimes", () => { expect(barrier.maxInFlight()).toBeLessThanOrEqual(2); }); - it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => { + it("interrupting a Python parallel() fan-out aborts in-flight subagents and preserves session state", async () => { using tempDir = TempDir.createSync("@omp-eval-agent-py-interrupt-"); const settings = Settings.isolated({ "async.enabled": false, @@ -683,20 +683,34 @@ describe("agent() through eval runtimes", () => { const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings); mockAgents(); // Each kernel worker thread blocks in a synchronous `urllib` bridge call, - // joined by `parallel()`'s ThreadPoolExecutor exit. The host must keep - // those already-started calls attached until they settle, then interrupt - // the kernel before `parallel()` launches another wave. + // joined by `parallel()`'s ThreadPoolExecutor exit. A turn cancel must + // reach the subagents those calls started — the bridge is handed the real + // signal, not the executor's kernel shield — while the kernel itself is + // still interrupted cleanly before `parallel()` launches another wave. let inFlight = 0; let completed = 0; + let abortedSubagents = 0; let markSaturated: (() => void) | undefined; const saturated = new Promise<void>(resolve => { markSaturated = resolve; }); - const releaseAgents = Promise.withResolvers<void>(); + // Mirrors the real executor: park until the run finishes *or* the caller's + // signal aborts. Nothing releases these agents, so the only way the cell + // can settle is the abort actually reaching them. const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => { // task.maxConcurrency=6 → six bridge calls block at once; signal then. if (++inFlight >= 6) markSaturated?.(); - await releaseAgents.promise; + const aborted = Promise.withResolvers<never>(); + const onAbort = () => aborted.reject(new Error("subagent aborted")); + options.signal?.addEventListener("abort", onAbort, { once: true }); + try { + await aborted.promise; + } catch { + abortedSubagents++; + return singleResult(options, { output: "", aborted: true, abortReason: "aborted by user" }); + } finally { + options.signal?.removeEventListener("abort", onAbort); + } completed++; return singleResult(options, { output: options.assignment ?? "" }); }); @@ -735,14 +749,15 @@ describe("agent() through eval runtimes", () => { await saturated; await Promise.resolve(); expect(completed).toBe(0); - releaseAgents.resolve(); const result = await resultPromise; - // Cancelled, but cleanly: no hard-kill, no orphaned bridge calls, and no - // second fan-out wave started after the deferred abort was delivered. + // The interrupt reached every in-flight subagent: nothing here released + // them, so the cell could only settle because the abort propagated. + expect(abortedSubagents).toBe(6); + expect(completed).toBe(0); + // Cancelled, but cleanly: no hard-kill, and no second fan-out wave started. expect(result.cancelled).toBe(true); expect(result.output).not.toContain("Python kernel shutdown"); - expect(completed).toBe(6); expect(runSpy).toHaveBeenCalledTimes(6); // The persistent kernel survived the interrupt: prior state is intact. @@ -897,7 +912,9 @@ describe("agent() through eval runtimes", () => { // The bridge paused the watchdog; the subprocess is now blocked in flight. await inFlight; - expect(observedMaxRuntimeMs).toBe(0); + // `agent()` must not pin the wall-clock cap: leaving it unset lets the + // executor inherit `task.maxRuntimeMs` exactly like the task tool does. + expect(observedMaxRuntimeMs).toBeUndefined(); // Burn far more than the 20ms budget while paused: the watchdog stays armed-off. vi.advanceTimersByTime(1_000); expect(idle.signal.aborted).toBe(false); @@ -991,6 +1008,65 @@ describe("agent() through eval runtimes", () => { expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP); expect(idle.signal.aborted).toBe(false); }); + + it("interrupting a JavaScript agent() aborts it at once but waits out its critical phase", async () => { + // Regression: `onAbort` used to hard-kill the worker straight away, which + // rejected the run while the untracked `handleToolCall` promise carried on + // — so an isolation merge could keep cherry-picking after the cell had + // already returned. Mirrors the Python bridge's shielded-signal contract. + // + // Asserted as an ordering, not a duration: the agent call must finish + // before the cell settles. Killing early inverts the two. + using tempDir = TempDir.createSync("@omp-eval-agent-js-interrupt-"); + const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-interrupt"); + mockAgents(); + + const order: string[] = []; + const inFlight = Promise.withResolvers<void>(); + const sawAbort = Promise.withResolvers<void>(); + const release = Promise.withResolvers<void>(); + // Stands in for the isolation merge: notices the abort, then keeps going. + vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => { + options.signal?.addEventListener( + "abort", + () => { + order.push("agent-saw-abort"); + sawAbort.resolve(); + }, + { once: true }, + ); + inFlight.resolve(); + await release.promise; + order.push("agent-returned"); + return singleResult(options, { output: "merged" }); + }); + + const ac = new AbortController(); + const cell = executeJs('return await agent("merge");', { + cwd: tempDir.path(), + sessionId: "agent-bridge-js-interrupt", + session, + sessionFile, + signal: ac.signal, + }).finally(() => { + order.push("cell-settled"); + }); + + await inFlight.promise; + ac.abort(new Error("external interrupt")); + // Delegated work is notified immediately, before anything is released. + await sawAbort.promise; + // Drain the microtask queue. An abort that settled the run outright would + // have resolved the cell by now; no wall clock is involved. + for (let i = 0; i < 200; i++) await Promise.resolve(); + expect(order).toEqual(["agent-saw-abort"]); + + release.resolve(); + const result = await cell; + + expect(order).toEqual(["agent-saw-abort", "agent-returned", "cell-settled"]); + expect(result.cancelled).toBe(true); + }, 30_000); }); describe("runEvalAgent isolation", () => { diff --git a/packages/coding-agent/test/eval/bridge-timeout.test.ts b/packages/coding-agent/test/eval/bridge-timeout.test.ts index 76137f1dd..c417f82e0 100644 --- a/packages/coding-agent/test/eval/bridge-timeout.test.ts +++ b/packages/coding-agent/test/eval/bridge-timeout.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { Settings } from "../../src/config/settings"; import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, @@ -8,6 +11,35 @@ import { import { executeWithKernelBase, type GenericKernel } from "../../src/eval/executor-base"; import type { JsStatusEvent } from "../../src/eval/js/shared/types"; import type { KernelDisplayOutput } from "../../src/eval/py/display"; +import * as pyToolBridge from "../../src/eval/py/tool-bridge"; +import type { ToolSession } from "../../src/tools"; + +/** Minimal `ToolSession` exposing `tools` to the eval tool bridge. */ +function makeToolSession(...tools: AgentTool[]): ToolSession { + return { + cwd: process.cwd(), + hasUI: false, + settings: Settings.isolated({ "async.enabled": false }), + taskDepth: 0, + enableLsp: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + getActiveModelString: () => "p/active", + getModelString: () => "p/fallback", + getArtifactsDir: () => null, + getSessionId: () => "bridge-timeout-session", + getEvalSessionId: () => "bridge-timeout-eval-session", + getToolByName: name => tools.find(tool => tool.name === name), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(async () => { + await pyToolBridge.disposePyToolBridge(); +}); describe("withBridgeTimeoutPause", () => { it("emits one pause before the operation and one resume after it settles", async () => { @@ -168,3 +200,146 @@ it("does not defer external aborts for a completion bridge call", async () => { expect(result.cancelled).toBe(true); expect(result.exitCode).toBeUndefined(); }); + +it("hands the tool bridge the unshielded signal so a deferred phase still cancels subagents", async () => { + // Regression: the bridge used to receive the kernel shield, so `agent()` + // fan-outs from a Python/Ruby/Julia cell survived a turn cancel and kept + // running until they finished on their own. + const abortController = new AbortController(); + const entered = Promise.withResolvers<void>(); + const observedKernelAbort = Promise.withResolvers<boolean>(); + const release = Promise.withResolvers<void>(); + let bridgeSignal: AbortSignal | undefined; + const registerSpy = vi + .spyOn(pyToolBridge, "registerPyToolBridge") + .mockImplementation((_sessionId, _runId, entry) => { + bridgeSignal = entry.signal; + return () => {}; + }); + + const kernel: GenericKernel<Record<string, string | null>> = { + async execute(_code, options) { + entered.resolve(); + options.onDisplay({ + type: "status", + event: { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true }, + } satisfies KernelDisplayOutput); + abortController.abort(new Error("external interrupt")); + observedKernelAbort.resolve(options.signal?.aborted ?? false); + await release.promise; + options.onDisplay({ + type: "status", + event: { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true }, + } satisfies KernelDisplayOutput); + return { status: "ok", cancelled: false, timedOut: false }; + }, + }; + + const resultPromise = executeWithKernelBase({ + kernel, + code: "agent('slow')", + options: { + signal: abortController.signal, + toolSession: makeToolSession(), + bridgeSessionId: "bridge-session", + }, + runIdPrefix: "test", + errorLogLabel: "test", + cancelledErrorClass: TestCancelledError, + buildKernelEnvPatch: () => ({}), + formatKernelTimeoutAnnotation: () => "kernel timed out", + formatTimeoutAnnotation: () => "timed out", + }); + + await entered.promise; + // The kernel stays shielded mid-phase so the runtime can't die during an + // isolation merge... + expect(await observedKernelAbort.promise).toBe(false); + // ...but everything dispatched through the bridge cancels right away. + expect(registerSpy).toHaveBeenCalledTimes(1); + expect(bridgeSignal?.aborted).toBe(true); + + release.resolve(); + const result = await resultPromise; + expect(result.cancelled).toBe(true); +}); + +it("holds the cell open through a deferred phase while still aborting the tool at once", async () => { + // Regression: the tool call and the wait-for-result race must use different + // aborts. Sharing the raw signal answered the HTTP call the moment the turn + // was cancelled, letting the cell settle and the bridge unregister on top of + // an abort-insensitive isolation merge still rewriting the repo. + // + // The discriminator is the reply itself, not its timing: losing the race + // makes the bridge answer `ok: false, aborted`, while a correctly deferred + // wait answers with the tool's real value once the phase releases. + const bridge = await pyToolBridge.ensurePyToolBridge(); + const abortController = new AbortController(); + const toolStarted = Promise.withResolvers<void>(); + const toolSawAbort = Promise.withResolvers<void>(); + const releaseTool = Promise.withResolvers<void>(); + + // Stands in for `runStructuredSubagent`: observes its abort at once (the + // subagent dies) but keeps working afterwards, exactly like a cherry-pick + // that never looks at a signal. + const parkedTool: AgentTool = { + name: "merge", + label: "Merge", + description: "Parks until released, ignoring its abort", + parameters: type({}), + execute: async (_id, _args, signal) => { + signal?.addEventListener("abort", () => toolSawAbort.resolve(), { once: true }); + toolStarted.resolve(); + await releaseTool.promise; + return { content: [{ type: "text", text: "merged" }] }; + }, + }; + const toolSession = makeToolSession(parkedTool); + + const bridgeSessionId = `bridge-${crypto.randomUUID()}`; + let reply: { ok: boolean; value?: unknown; error?: string } | undefined; + const kernel: GenericKernel<Record<string, string | null>> = { + async execute(_code, options) { + options.onDisplay({ + type: "status", + event: { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true }, + } satisfies KernelDisplayOutput); + // Mirrors the Python prelude's blocking loopback call. + const pending = fetch(`${bridge.url}/v1/tool`, { + method: "POST", + headers: { authorization: `Bearer ${bridge.token}`, "content-type": "application/json" }, + body: JSON.stringify({ session: bridgeSessionId, run: options.id, name: "merge", args: {} }), + }).then(res => res.json() as Promise<{ ok: boolean; value?: unknown; error?: string }>); + + await toolStarted.promise; + abortController.abort(new Error("external interrupt")); + // The raw signal reached the tool: delegated work is already dying. + await toolSawAbort.promise; + + releaseTool.resolve(); + reply = await pending; + options.onDisplay({ + type: "status", + event: { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true }, + } satisfies KernelDisplayOutput); + return { status: "ok", cancelled: false, timedOut: false }; + }, + }; + + const result = await executeWithKernelBase({ + kernel, + code: "agent('isolated')", + options: { signal: abortController.signal, toolSession, bridgeSessionId }, + runIdPrefix: "test", + errorLogLabel: "test", + cancelledErrorClass: TestCancelledError, + buildKernelEnvPatch: () => ({}), + formatKernelTimeoutAnnotation: () => "kernel timed out", + formatTimeoutAnnotation: () => "timed out", + }); + + // The host waited out the critical phase instead of bailing on the cancel. + expect(reply).toEqual({ ok: true, value: "merged" }); + // ...and the turn is still reported as cancelled. + expect(result.cancelled).toBe(true); +}); diff --git a/packages/coding-agent/test/eval/js-context-manager.test.ts b/packages/coding-agent/test/eval/js-context-manager.test.ts index e127781b8..6eb97c789 100644 --- a/packages/coding-agent/test/eval/js-context-manager.test.ts +++ b/packages/coding-agent/test/eval/js-context-manager.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { TempDir } from "@oh-my-pi/pi-utils"; import { Settings } from "../../src/config/settings"; import { @@ -22,9 +24,14 @@ interface FakeWorkerBehavior { exitOnClose: boolean; settleRuns: boolean; errorOnStart?: boolean; + /** + * Reproduces `WorkerCore#runOne` for a floated bridge call: start a tool call + * and report the run finished in the same turn, without awaiting the call. + */ + floatingToolCall?: string; } -function makeSession(cwd: string): ToolSession { +function makeSession(cwd: string, ...tools: AgentTool[]): ToolSession { return { cwd, hasUI: false, @@ -42,6 +49,7 @@ function makeSession(cwd: string): ToolSession { getArtifactsDir: () => null, getSessionId: () => "test-session", getEvalSessionId: () => "test-eval-session", + getToolByName: name => tools.find(tool => tool.name === name), }; } @@ -108,6 +116,20 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior) postMessage(message: unknown): void { if (!message || typeof message !== "object") return; const typed = message as { type?: string; runId?: string }; + if (typed.type === "run" && typed.runId && behavior.floatingToolCall) { + const runId = typed.runId; + queueMicrotask(() => { + this.#emitMessage({ + type: "tool-call", + runId, + id: `tc-${runId}`, + name: behavior.floatingToolCall, + args: {}, + }); + this.#emitMessage({ type: "result", runId, ok: true }); + }); + return; + } if (typed.type === "run" && typed.runId && behavior.settleRuns) { queueMicrotask(() => this.#emitMessage({ type: "result", runId: typed.runId, ok: true })); return; @@ -366,6 +388,57 @@ describe("JavaScript eval worker lifecycle", () => { // The errored primary worker is torn down before the inline retry takes over. expect(stats.terminateCalls).toBe(1); }); + + it("holds a finished run until its floated tool call drains", async () => { + // Regression, and the sharpest form of the runaway `agent()` fan-out — no + // cancellation required. `WorkerCore#runOne` reports a finished run without + // awaiting outstanding tool calls, so `agent(...)` with no `await` used to + // settle the cell immediately; `runOnce` then dropped the run's abort + // listener and pending entry, leaving the subagent running with nothing + // able to cancel it. A cell must own every bridge call it starts. + using tempDir = TempDir.createSync("@omp-js-worker-float-"); + const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; + installFakeWorker(stats, { exitOnClose: true, settleRuns: false, floatingToolCall: "park" }); + + const started = Promise.withResolvers<void>(); + const release = Promise.withResolvers<void>(); + let toolReturned = false; + const park: AgentTool = { + name: "park", + label: "Park", + description: "Parks until released", + parameters: type({}), + execute: async () => { + started.resolve(); + await release.promise; + toolReturned = true; + return { content: [{ type: "text", text: "parked" }] }; + }, + }; + const session = makeSession(tempDir.path(), park); + + let settled = false; + const cell = executeJs('tool.park({});\nreturn "floated";', { + cwd: tempDir.path(), + sessionId: `js-float:${crypto.randomUUID()}`, + session, + }).finally(() => { + settled = true; + }); + + await started.promise; + // The fake worker already reported the run finished, in the same microtask + // that started the call. Draining the microtask queue is exact here: the + // harness is queueMicrotask-driven, with no IPC or timers in the path. + for (let i = 0; i < 50; i++) await Promise.resolve(); + expect(settled).toBe(false); + expect(toolReturned).toBe(false); + + release.resolve(); + const result = await cell; + expect(toolReturned).toBe(true); + expect(result.exitCode).toBe(0); + }); }); describe.skipIf(process.platform === "win32")("JavaScript eval process isolation", () => { 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<string> { + 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<string, unknown> = {}; + 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<string, unknown> = {}; + applyToolProxy(new DemoTool(), wrapper); + const execute = wrapper.execute as () => Promise<string>; + // `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<string, unknown> = {}; + 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<string, unknown>; + schema: Record<string, unknown>; }; 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<string, unknown>; + 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<string, unknown>; + 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://<tool>`). */ 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 `<parameter>`-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/slash-commands/clear-alias.test.ts b/packages/coding-agent/test/slash-commands/clear-alias.test.ts index ce1609556..33249692b 100644 --- a/packages/coding-agent/test/slash-commands/clear-alias.test.ts +++ b/packages/coding-agent/test/slash-commands/clear-alias.test.ts @@ -5,8 +5,8 @@ import { } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; -describe("/clear slash command alias", () => { - it("ranks the new-session action above fuzzy description matches", async () => { +describe("/clear slash command", () => { + it("resolves /clear to the context reset command and removed /clear alias from /new", async () => { const provider = new CombinedAutocompleteProvider( [...BUILTIN_SLASH_COMMANDS, { name: "autoresearch", description: "Clear stale research results" }], process.cwd(), @@ -16,8 +16,10 @@ describe("/clear slash command alias", () => { expect(suggestions?.items[0]).toMatchObject({ value: "clear", - description: "Start a new session", + description: "Clear the conversation context in place, keeping the session", }); - expect(lookupBuiltinSlashCommand("clear")?.name).toBe("new"); + expect(lookupBuiltinSlashCommand("clear")?.name).toBe("clear"); + expect(lookupBuiltinSlashCommand("new")?.aliases).toBeUndefined(); + expect(lookupBuiltinSlashCommand("reset")).toBeUndefined(); }); }); 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/approval.test.ts b/packages/coding-agent/test/tools/approval.test.ts index c222a1713..26d829b92 100644 --- a/packages/coding-agent/test/tools/approval.test.ts +++ b/packages/coding-agent/test/tools/approval.test.ts @@ -329,6 +329,9 @@ describe("tool-owned dynamic approval declarations", () => { "git $(rm file.txt)", "git `rm file.txt` status", "git status > /etc/passwd", + "git -c alias.x='!touch /tmp/pwn; printf ok' x", + 'git -c alias.x="!touch /tmp/pwn; printf ok" x', + "git -c alias.x=!touch\\ /tmp/pwn\\;\\ printf\\ ok x", "git status < seed", // Different binary resolution than the pattern names. "FOO=1 git status", @@ -347,6 +350,16 @@ describe("tool-owned dynamic approval declarations", () => { } }); + it("allows literal shell metacharacters in quoted arguments", () => { + const settingsOverrides = { + "bash.patterns": [{ match: "cargo *", approval: "allow" }], + }; + const command = + "cargo bench --manifest-path layers/layer3/Cargo.toml --bench standardized_criterion -- --full '^layer3/write/file-wal/batch-(10|1000|10000)$'"; + + expect(bashApproval(command, settingsOverrides)).toEqual({ tier: "write", policy: "allow" }); + }); + it("honors bash pattern rules in yolo mode", () => { const tool = createBashTool({ "bash.patterns": [ 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> = {}): 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/hashline/package.json b/packages/hashline/package.json index 82ac368fb..81aa7620c 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "17.2.6", + "version": "17.2.8", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", 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/mnemopi/package.json b/packages/mnemopi/package.json index d54292db3..24a938afc 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "17.2.6", + "version": "17.2.8", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 7ae3337bb..5f4c8f991 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [17.2.7] - 2026-08-03 + +### Added + +- Added missing procps/BSD output format specifiers and aliases to the in-process `ps` shell builtin, including support for columns like `tpgid`, `pri`, `flags`, `wchan`, and various user/group/time fields. +- Updated `ps -j` to include the TPGID column, `ps -l` to display the single-character S column, and the STAT column to support the `+` foreground process group flag. + ## [17.2.6] - 2026-08-03 ### Added 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/natives/native/index.d.ts b/packages/natives/native/index.d.ts index a2885b4b8..0a0b89c25 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -279,7 +279,7 @@ export declare function __ompInstallTokioRuntime(): void * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV17_2_6(): void +export declare function __piNativesV17_2_8(): void /** * Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 04d68e530..cbf4f7405 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -29,7 +29,7 @@ export const Shell = nativeBindings.Shell; // functions export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime; -export const __piNativesV17_2_6 = nativeBindings.__piNativesV17_2_6; +export const __piNativesV17_2_8 = nativeBindings.__piNativesV17_2_8; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; export const astMatch = nativeBindings.astMatch; diff --git a/packages/natives/package.json b/packages/natives/package.json index 558192215..9ea41c710 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "17.2.6", + "version": "17.2.8", "description": "Native Rust bindings for audio, WebRTC, grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md new file mode 100644 index 000000000..9c6e03621 --- /dev/null +++ b/packages/omptype/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +## [Unreleased] + +## [17.2.8] - 2026-08-04 + +### Added + +- Added `io: 'input'` and `io: 'output'` options to `toJsonSchema()`, supporting input validation shapes and piped `.to()` target types +- Added Standard Schema V1 interop: every schema exposes `~standard` with synchronous validation, enabling direct use with `@t3-oss/env`, tRPC, and other Standard Schema consumers. +- Added `fromJsonSchema()`, rebuilding callable schemas from JSON Schema documents (draft-07 / draft-2020-12 structural keywords, string formats, `$defs` recursion, enums, and `anyOf`/`oneOf`/`allOf` composition) — the inverse of `Type.toJsonSchema()`. +- Added `$defs`/`$ref` emission for recursive alias schemas in `toJsonSchema()` (draft-07 converts to `definitions`), preventing unbounded recursion on cyclic scopes. +- Added `AnyType`, a minimal structural constraint for generic functions accepting any schema without descending the recursive fluent surface. +- Root `.default()` values now materialize for `undefined` input in direct calls and at the Standard Schema boundary (factories run per call). +- `.narrow()`/`.filter()` boolean overloads accept `OmpErrors` returns, so `cond || ctx.reject(...)` recipes typecheck. + +### Changed + +- Restored low-overhead schema construction by lazily activating advanced normalization and compatibility machinery. +- `.default()` is typed input-side (`i | (() => i)`) and marks the schema's input as optional (`i | undefined`). +- Parse keywords (`string.integer.parse`, `parse.number`, ...) now infer their morph output inside union strings, and input-side inference is union-aware. +- Object-literal inference for `.merge()`/`.or()`/`.and()` unwraps embedded schema values (output and input sides). + +### Fixed + +- Alias intersections defer through memoized lazy nodes, so cyclic scope schemas no longer overflow the stack in `.and()` or morph-union determinism checks. + +## [17.2.7] - 2026-08-03 + +### Added + +- Introduced omptype, an ArkType-compatible schema validation library featuring a lazy JIT runtime that compiles specialized validators on the third call for ultra-fast hot-path validation and low construction overhead. +- Added support for a rich string definition DSL (primitives, literals, unions, arrays, bounds, inline defaults, and optional keys), object definitions (including index signatures and strict key rejection/deletion), and comprehensive composition methods (.or, .and, .array, .pipe, .narrow, .describe, .default, .allows, .assert). +- Added TypeBox-style (@oh-my-pi/omptype/typebox) and Zod-style (@oh-my-pi/omptype/zod) authoring adapters that produce native omptype schemas. +- Added support for recursive named scopes, modules, runtime generics, fixed/optional/variadic tuples, Date literals/bounds, disjointness-aware intersections, separate input/output inference, and draft-2020-12 JSON Schema emission. +- Shipped transpiled ESM and TypeScript declarations in the npm package to support plain Node.js environments, while preserving TS source resolution for Bun consumers. + +### Changed + +- Optimized the lazy JIT compiler to support tuples, refinements, morphs, intersections, instances, and recursive aliases, while reducing schema construction overhead. + +### Fixed + +- Fixed a TypeScript compiler error (TS2589: "type instantiation is excessively deep") when using generic fluent composition methods on nested schemas. +- Fixed type.raw() results (BaseType) to correctly expose fluent composition methods like .array(), .or(), and .pipe(). +- Fixed an issue in the TypeBox adapter where keyword-carrying schemas (e.g., uniqueItems arrays) would throw an error during JSON Schema emission. diff --git a/packages/omptype/README.md b/packages/omptype/README.md new file mode 100644 index 000000000..aa7a800f3 --- /dev/null +++ b/packages/omptype/README.md @@ -0,0 +1,129 @@ +# @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<User, 'name'>", + }) + .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("<value>", 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<typeof TypeBoxUser>; + +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. + +## Performance + +Run the benchmark from the repository root: + +```sh +bun packages/omptype/bench/bench.ts +``` + +The harness first requires every candidate to accept, reject, and transform the +same fixtures correctly. Compile and cold-start results use 400 unique object +schemas and report the fastest of five repetitions. Hot validation mixes valid +and invalid inputs after 2,000 warmup calls. The valid-only row uses each +library's public boolean path after 20,000 warmup calls. + +Representative result on an Apple M4 Max with Darwin 25.6.0 and Bun 1.3.14: + +| Phase | omptype | ArkType | Zod | TypeBox | +| ----------------------- | ---------: | ----------------: | ----------------: | --------------: | +| Compile `type()` | **509ns** | 271.08µs (532.3×) | 77.95µs (153.1×) | 27.36µs (53.7×) | +| Compile + 2 validations | **2.18µs** | 526.46µs (241.5×) | 222.55µs (102.1×) | 46.90µs (21.5×) | + +| Hot workload | omptype | ArkType | Zod | TypeBox | +| -------------------------- | -------: | --------------: | --------------: | --------------: | +| `flat-small` | **25ns** | 5.10µs (203.7×) | 4.55µs (181.4×) | 1.23µs (49.2×) | +| `enum-union` | **27ns** | 4.92µs (185.0×) | 4.38µs (164.7×) | 2.20µs (83.0×) | +| `nested-arrays` | **29ns** | 4.80µs (163.0×) | 7.05µs (239.6×) | 3.01µs (102.2×) | +| `strict-defaults` | **40ns** | 4.85µs (122.1×) | 6.71µs (169.2×) | 4.92µs (123.9×) | +| `delete-extras` | **22ns** | 4.12µs (191.6×) | 2.55µs (118.6×) | 2.07µs (96.0×) | +| `record-mixed` | **43ns** | 4.32µs (100.4×) | 8.80µs (204.5×) | 3.35µs (77.9×) | +| `deep-message` | **31ns** | 6.32µs (202.5×) | 9.16µs (293.7×) | 5.13µs (164.5×) | +| `nested-arrays` valid-only | **15ns** | 28ns (1.8×) | 1.05µs (68.3×) | 45ns (2.9×) | + +Lower times are better. Parenthetical values show how many times slower each +candidate was than omptype in this run. Results vary with hardware, runtime, +thermal state, and dependency versions; use the command above for local +measurements. + +## License + +MIT diff --git a/packages/omptype/bench/bench.ts b/packages/omptype/bench/bench.ts new file mode 100644 index 000000000..6d09504b1 --- /dev/null +++ b/packages/omptype/bench/bench.ts @@ -0,0 +1,207 @@ +/** + * 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; + let allows: ((value: unknown) => boolean) | undefined; + try { + schema = candidate.type(fixture.def); + allows = candidate.allows?.(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; + } + if (allows?.(structuredClone(value)) === false) { + console.error( + `✗ ${candidate.name}/${fixture.name}: allows() rejected valid input: ${JSON.stringify(value)}`, + ); + 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; + } + if (allows?.(structuredClone(value))) { + console.error( + `✗ ${candidate.name}/${fixture.name}: allows() accepted invalid input: ${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 = 2_000_000; + const rows: Row[] = []; + const fixture = FIXTURES[2]; + for (const candidate of candidates) { + let validate = candidate.allows?.(fixture.def); + if (validate === undefined) { + const schema = candidate.type(fixture.def); + validate = (value: unknown) => !candidate.isErrors(schema(value)); + } + for (let index = 0; index < 20_000; index++) validate(fixture.valid[index % fixture.valid.length]); + Bun.gc(true); + let sink = 0; + const start = Bun.nanoseconds(); + for (let index = 0; index < iterations; index++) { + if (validate(fixture.valid[index % fixture.valid.length])) 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 allows/check 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..0e83558ed --- /dev/null +++ b/packages/omptype/bench/candidate.ts @@ -0,0 +1,24 @@ +/** + * 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"; + +/** Callable validation result contract used by timed candidates. */ +export type SchemaFn = (value: unknown) => unknown; +/** Boolean-only validation contract used by timed candidates. */ +export type CheckFn = (value: unknown) => boolean; + +/** One library implementation exercised by the benchmark harness. */ +export interface Candidate { + name: string; + type(def: Def): SchemaFn; + /** Native boolean-only validation path, when the library exposes one. */ + allows?(def: Def): CheckFn; + 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..87271eddc --- /dev/null +++ b/packages/omptype/bench/candidates/arktype.ts @@ -0,0 +1,17 @@ +/** 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); + }, + allows(def: Def) { + return type(def as never).allows; + }, + 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..1af8336c2 --- /dev/null +++ b/packages/omptype/bench/candidates/omptype.ts @@ -0,0 +1,18 @@ +/** 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); + }, + allows(def: Def) { + const schema = type(def as never); + return (value: unknown) => schema.allows(value); + }, + 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..d7c9b183e --- /dev/null +++ b/packages/omptype/bench/candidates/typebox.ts @@ -0,0 +1,172 @@ +/** + * Pipeline: Type.Object builds each schema and TypeCompiler.Compile compiles + * its hot `Check` path. Failures materialize TypeBox's native first error. + * 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 type { ValueError } from "@sinclair/typebox/errors"; +import { Value } from "@sinclair/typebox/value"; +import type { Candidate } from "../candidate"; +import type { Def } from "../ir"; + +class TypeBoxErrors { + readonly first: ValueError; + + constructor(first: ValueError) { + this.first = first; + } +} + +function errorResult(first: ValueError | undefined): TypeBoxErrors { + if (first === undefined) throw new Error("TypeBox Check failed without an error"); + return new TypeBoxErrors(first); +} + +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<string, Def>): 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<string, Def>; + 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 errorResult(compiled.Errors(defaulted).First()); + const output = Value.Clean(schema, defaulted); + return compiled.Check(output) ? output : errorResult(compiled.Errors(output).First()); + }; + } + if (morph === "clean") { + return (value: unknown) => { + const defaulted = Value.Default(schema, structuredClone(value)); + const output = Value.Clean(schema, defaulted); + return compiled.Check(output) ? output : errorResult(compiled.Errors(output).First()); + }; + } + return (value: unknown) => (compiled.Check(value) ? value : errorResult(compiled.Errors(value).First())); + }, + allows(definition) { + const { schema, morph } = buildSchema(definition); + if (morph === "none") { + const compiled = TypeCompiler.Compile(schema); + return (value: unknown) => compiled.Check(value); + } + const run = typeboxCandidate.type(definition); + return (value: unknown) => !typeboxCandidate.isErrors(run(value)); + }, + isErrors: result => result instanceof TypeBoxErrors, + summary: result => (result instanceof TypeBoxErrors ? 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..ab6141471 --- /dev/null +++ b/packages/omptype/bench/candidates/zod.ts @@ -0,0 +1,121 @@ +/** + * 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<string, Def>): 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<string, Def>; + 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; + }; + }, + allows(definition) { + const schema = buildSchema(definition); + return (value: unknown) => schema.safeParse(value).success; + }, + 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<string, Def>)[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..7d177dcfd --- /dev/null +++ b/packages/omptype/package.json @@ -0,0 +1,64 @@ +{ + "type": "module", + "name": "@oh-my-pi/omptype", + "version": "17.2.8", + "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": { + "@ark/attest": "0.56.3", + "@ark/schema": "0.56.2", + "@ark/util": "0.56.2", + "@sinclair/typebox": "^0.34.0", + "@types/bun": "catalog:", + "arkregex": "0.0.8", + "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..2e6f34e1c --- /dev/null +++ b/packages/omptype/src/compile.ts @@ -0,0 +1,1148 @@ +/** + * 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, materializeDefault, 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$]*$/; + +/** 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 }; + +type LiteralIR = Extract<IR, { k: "lit" }>; + +function isPrimitiveLiteral(node: IR): node is LiteralIR { + return node.k === "lit" && (node.v === null || (typeof node.v !== "object" && typeof node.v !== "function")); +} + +/** Whether `undefined` necessarily fails, allowing property-presence checks to be elided. */ +function rejectsUndefined(node: IR): boolean { + switch (node.k) { + case "unknown": + case "undefined": + case "alias": + case "sub": + return false; + case "lit": + return node.v !== undefined; + case "union": + return node.members.every(rejectsUndefined); + case "intersection": + return node.members.some(rejectsUndefined); + case "refine": + return rejectsUndefined(node.base); + case "morph": + return rejectsUndefined(node.input); + default: + return true; + } +} + +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<IR> | 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); + } + + access(base: string, key: PropertyKey): string { + return typeof key === "string" && IDENT.test(key) ? `${base}.${key}` : `${base}[${this.lit(key)}]`; + } + + pathExpr(segs: PathSeg[]): string { + const parts = segs.map(seg => ("s" in seg ? this.lit(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 ? this.lit(seg.s) : seg.d; + } + const staticParts: PropertyKey[] = []; + for (const seg of segs) { + if ("d" in seg) return this.pathExpr(segs); + staticParts.push(seg.s); + } + return this.ref(staticParts); + } + + error(segs: PathSeg[], expected: string, dataExpr: string): string { + return `new AE(${this.storedPathExpr(segs)},${JSON.stringify(expected)},${dataExpr})`; + } + + fail(segs: PathSeg[], expected: string, dataExpr: string): string { + return `return ${this.error(segs, expected, dataExpr)}`; + } + + appendError(errors: string, error: string): string { + return `if(${errors}===undefined)${errors}=${error};else ${errors}.append(${error});`; + } + + /** 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(isPrimitiveLiteral); + if (lits.length > 8) { + const values = this.ref(new Set(lits.map(member => member.v))); + const literalNodes = new Set<IR>(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 array = this.next("a"); + const index = this.next("i"); + let out = `Array.isArray(${v})`; + if (node.min !== undefined) out += `&&${v}.length>=${node.min}`; + if (node.max !== undefined) out += `&&${v}.length<=${node.max}`; + const item = `${array}[${index}]`; + out += `&&((${array})=>{for(let ${index}=0;${index}<${array}.length;${index}++)if(!(${this.predicate(node.el, item)}))return false;return true})(${v})`; + return out; + } + case "object": { + const checks = [`typeof ${v}==="object"`, `${v}!==null`]; + for (const p of node.props) { + const av = this.access(v, p.key); + const present = `${this.lit(p.key)} in ${v}`; + const predicate = this.predicate(p.val, av); + checks.push( + p.opt || p.hasDefault + ? rejectsUndefined(p.val) + ? `((${av}!==undefined&&(${predicate}))||!(${present}))` + : `(!(${present})||(${predicate}))` + : rejectsUndefined(p.val) + ? predicate + : `((${present})&&(${predicate}))`, + ); + } + const stringKey = this.next("k"); + if (node.index !== undefined) { + checks.push( + `(()=>{for(const ${stringKey} in ${v})if(own.call(${v},${stringKey})&&!(${this.predicate(node.index, `${v}[${stringKey}]`)}))return false;return true})()`, + ); + } + if (node.patternIndexes !== undefined) { + for (const pattern of node.patternIndexes) { + checks.push( + `(()=>{for(const ${stringKey} in ${v})if(own.call(${v},${stringKey})&&(${this.predicate(pattern.key, stringKey)})&&!(${this.predicate(pattern.val, `${v}[${stringKey}]`)}))return false;return true})()`, + ); + } + } + if (node.symbolIndex !== undefined) { + const symbol = this.next("s"); + checks.push( + `(()=>{for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.predicate(node.symbolIndex, `${v}[${symbol}]`)}))return false;return true})()`, + ); + } + if (node.extras === "reject") { + const patternMatch = + node.patternIndexes?.map(pattern => `(${this.predicate(pattern.key, stringKey)})`).join("||") ?? + "false"; + if (node.index === undefined) { + checks.push( + `(()=>{for(const ${stringKey} in ${v})if(own.call(${v},${stringKey})&&!(${this.declaredCheck(node.props, stringKey)})&&!(${patternMatch}))return false;return true})()`, + ); + } + if (node.symbolIndex === undefined) { + const symbol = this.next("s"); + checks.push( + `(()=>{for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.declaredCheck(node.props, symbol)}))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}===${this.lit(p.key)}`).join("||")})`; + } + + /** + * Run a node through its interpreter/sub-schema runner, appending any + * failure to `errors`. `brk` (when given) exits the enclosing block on + * failure so dependent statements (output assignment, morph fns) are + * skipped. Both runner kinds receive the absolute path so nested step + * callbacks observe ctx.path; walk-produced errors are already absolute, + * while sub runners return schema-relative errors that need prefixing. + */ + emitCollectDelegate(node: IR, v: string, segs: PathSeg[], errors: string, brk?: string, out?: string): void { + const sub = node.k === "sub"; + const runner = sub ? node.schema.run : boundWalk(node); + const result = this.next("r"); + const args = segs.length > 0 ? `${v},${this.pathExpr(segs)}` : v; + this.push(`const ${result}=${this.ref(runner)}(${args});`); + const failure = sub && segs.length > 0 ? `PF(${result},${this.pathExpr(segs)})` : result; + this.push( + `if(${result} instanceof AE){${this.appendError(errors, failure)}${brk === undefined ? "" : `break ${brk};`}}`, + ); + if (out !== undefined) this.push(`${out}=${result};`); + } + + /** Snapshot the error count so sequencing sites can detect soft failures. */ + markErrors(errors: string): string { + const mark = this.next("n"); + this.push(`const ${mark}=${errors}===void 0?0:${errors}.length;`); + return mark; + } + + /** Exit `brk` when errors were appended since `mark` (interp's return-on-error). */ + guardGrowth(errors: string, mark: string, brk: string): void { + this.push(`if((${errors}===void 0?0:${errors}.length)!==${mark})break ${brk};`); + } + + /** Aggregate every independent failure in a morph-free subtree. */ + emitCollectCheck(node: IR, v: string, segs: PathSeg[], errors: string, failureData = v): void { + if (node.cfg !== undefined || node.k === "refine") { + this.emitCollectDelegate(node, v, segs, errors); + return; + } + switch (node.k) { + case "unknown": + return; + case "array": { + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}}`, + ); + if (node.min !== undefined) { + this.push( + `else if(${v}.length<${node.min}){${this.appendError( + errors, + this.error(segs, `at least length ${node.min}`, `${v}.length`), + )}}`, + ); + } + if (node.max !== undefined) { + this.push( + `else if(${v}.length>${node.max}){${this.appendError( + errors, + this.error(segs, `at most length ${node.max}`, `${v}.length`), + )}}`, + ); + } + this.push("else{"); + const index = this.next("i"); + this.push(`for(let ${index}=0;${index}<${v}.length;${index}++){`); + this.emitCollectCheck(node.el, `${v}[${index}]`, [...segs, { d: index }], errors); + this.push("}}"); + return; + } + case "tuple": { + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}}else{`, + ); + const requiredPrefix = node.prefix.filter(item => !item.opt && !item.hasDefault).length; + const minimum = requiredPrefix + node.postfix.length; + const maximum = node.prefix.length + node.postfix.length; + if (minimum > 0) { + this.push( + `if(${v}.length<${minimum}){${this.appendError( + errors, + this.error(segs, `an array of at least length ${minimum}`, failureData), + )}}else{`, + ); + } + if (node.variadic === undefined) { + this.push( + `if(${v}.length>${maximum}){${this.appendError( + errors, + this.error(segs, `an array of at most length ${maximum}`, failureData), + )}}else{`, + ); + } + 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});`); + } + for (let index = 0; index < node.prefix.length; index++) { + if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); + this.emitCollectCheck(node.prefix[index].val, `${v}[${index}]`, [...segs, { d: String(index) }], errors); + if (index >= requiredPrefix) this.push("}"); + } + if (node.variadic !== undefined) { + const index = this.next("i"); + this.push(`for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){`); + this.emitCollectCheck(node.variadic, `${v}[${index}]`, [...segs, { d: index }], errors); + this.push("}"); + } + for (let index = 0; index < node.postfix.length; index++) { + const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; + this.emitCollectCheck(node.postfix[index], `${v}[${inputIndex}]`, [...segs, { d: inputIndex }], errors); + } + if (node.variadic === undefined) this.push("}"); + if (minimum > 0) this.push("}"); + this.push("}"); + return; + } + case "object": { + if ( + node.patternIndexes !== undefined || + node.symbolIndex !== undefined || + node.props.some(prop => typeof prop.key === "symbol") + ) { + this.emitCollectDelegate(node, v, segs, errors); + return; + } + this.push( + `if(typeof ${v}!=="object"||${v}===null){${this.appendError( + errors, + this.error(segs, "an object", failureData), + )}}else{`, + ); + for (const prop of node.props) { + const present = `${this.lit(prop.key)} in ${v}`; + const propSegs: PathSeg[] = [...segs, { s: prop.key }]; + if (prop.opt || prop.hasDefault) { + this.push(`if(${present}){`); + this.emitCollectCheck(prop.val, this.access(v, prop.key), propSegs, errors); + this.push("}"); + } else { + this.push( + `if(!(${present})){${this.appendError( + errors, + this.error(propSegs, expectedOf(prop.val), "M"), + )}}else{`, + ); + this.emitCollectCheck(prop.val, this.access(v, prop.key), propSegs, errors); + this.push("}"); + } + } + if (node.index !== undefined) { + const key = this.next("k"); + this.push(`for(const ${key} in ${v})if(own.call(${v},${key})){`); + this.emitCollectCheck(node.index, `${v}[${key}]`, [...segs, { d: key }], errors); + this.push("}"); + } else if (node.extras === "reject") { + const key = this.next("k"); + this.push( + `for(const ${key} in ${v})if(own.call(${v},${key})&&!(${this.declaredCheck(node.props, key)})){`, + ); + this.push(this.appendError(errors, this.error([...segs, { d: key }], "removed", `${v}[${key}]`))); + this.push("}"); + } + if (node.extras === "reject") { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.declaredCheck(node.props, symbol)})){${this.appendError(errors, this.error([...segs, { d: symbol }], "removed", `${v}[${symbol}]`))}}`, + ); + } + this.push("}"); + return; + } + case "union": { + const failure = node.members.some(canRefineUnionFailure) + ? `UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))})` + : this.error(segs, expectedOf(node), failureData); + this.push(`if(!(${this.predicate(node, v)})){${this.appendError(errors, failure)}}`); + return; + } + case "string": { + this.push( + `if(typeof ${v}!=="string"){${this.appendError(errors, this.error(segs, "a string", failureData))}}`, + ); + if (node.min !== undefined) { + this.push( + `else if(${v}.length<${node.min}){${this.appendError( + errors, + this.error(segs, `at least length ${node.min}`, `${v}.length`), + )}}`, + ); + } + if (node.max !== undefined) { + this.push( + `else if(${v}.length>${node.max}){${this.appendError( + errors, + this.error(segs, `at most length ${node.max}`, `${v}.length`), + )}}`, + ); + } + if (node.url) { + this.push( + `else if(!URL.canParse(${v})){${this.appendError( + errors, + this.error(segs, "a URL string", failureData), + )}}`, + ); + } + return; + } + case "number": { + this.push( + `if(typeof ${v}!=="number"||!Number.isFinite(${v})){${this.appendError( + errors, + this.error(segs, node.int ? "an integer" : "a number", failureData), + )}}else{`, + ); + if (node.int) { + this.push( + `if(!Number.isInteger(${v})){${this.appendError(errors, this.error(segs, "an integer", failureData))}}`, + ); + } + if (node.divisor !== undefined) { + this.push( + `if(${v}%${node.divisor}!==0){${this.appendError( + errors, + this.error(segs, `a number divisible by ${node.divisor}`, failureData), + )}}`, + ); + } + if (node.min !== undefined) { + const expected = + node.min === 0 + ? node.xmin + ? "positive" + : "non-negative" + : `a number ${node.xmin ? "more than" : "at least"} ${node.min}`; + this.push( + `if(${v}${node.xmin ? "<=" : "<"}${node.min}){${this.appendError( + errors, + this.error(segs, expected, failureData), + )}}`, + ); + } + if (node.max !== undefined) { + const expected = + node.max === 0 + ? node.xmax + ? "negative" + : "non-positive" + : `a number ${node.xmax ? "less than" : "at most"} ${node.max}`; + this.push( + `if(${v}${node.xmax ? ">=" : ">"}${node.max}){${this.appendError( + errors, + this.error(segs, expected, failureData), + )}}`, + ); + } + this.push("}"); + return; + } + case "lit": + if ( + (node.v !== null && typeof node.v === "object" && !(node.v instanceof Date)) || + typeof node.v === "function" + ) { + this.emitCollectDelegate(node, v, segs, errors); + } else { + this.push( + `if(!(${this.predicate(node, v)})){${this.appendError( + errors, + this.error(segs, expectedOf(node), failureData), + )}}`, + ); + } + return; + case "intersection": + case "sub": + this.emitCollectDelegate(node, v, segs, errors); + return; + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + case "anyobject": + case "instance": + this.push( + `if(!(${this.predicate(node, v)})){${this.appendError( + errors, + this.error(segs, expectedOf(node), failureData), + )}}`, + ); + return; + default: + this.emitCollectDelegate(node, v, segs, errors); + } + } + + emitTupleShape( + node: TupleIR, + v: string, + segs: PathSeg[], + errors: string, + brk: string, + failureData: string, + ): { postfixStart: string; prefixCount: string; requiredPrefix: number } { + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}break ${brk};}`, + ); + 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.appendError( + errors, + this.error(segs, `an array of at least length ${minimum}`, failureData), + )}break ${brk};}`, + ); + } + if (node.variadic === undefined) { + const maximum = node.prefix.length + node.postfix.length; + this.push( + `if(${v}.length>${maximum}){${this.appendError( + errors, + this.error(segs, `an array of at most length ${maximum}`, failureData), + )}break ${brk};}`, + ); + } + 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 }; + } + + /** Fill `target` with a validated default (factory output revalidated per call). */ + emitDefaultFill(val: IR, def: unknown, isFactory: boolean, target: string, segs: PathSeg[], errors: string): void { + if (isFactory && typeof def === "function") { + const candidate = this.next("d"); + const resolved = this.next("t"); + const label = this.next("L"); + this.push(`const ${candidate}=${this.ref(def)}();let ${resolved};${label}:{`); + this.emitCollectProduce(val, candidate, segs, resolved, errors, label); + this.push(`${target}=${resolved};}`); + } else { + // Static defaults were prevalidated at construction; MD clones + // mutable payloads so callers cannot alias the schema's copy. + this.push(`${target}=${litSource(def) ?? `MD(${this.ref(def)})`};`); + } + } + + /** + * Validate `v` against a morphing subtree and assign the produced output + * to `out` (an already-declared `let`). Failures append to `errors` and + * `break ${brk}` (skipping the output assignment), mirroring interp: an + * error in one child never suppresses sibling validation or morphs. + */ + emitCollectProduce( + node: IR, + v: string, + segs: PathSeg[], + out: string, + errors: string, + brk: string, + failureData = v, + ): void { + if (node.cfg !== undefined || node.k === "refine") { + this.emitCollectDelegate(node, v, segs, errors, brk, out); + return; + } + if (!hasMorph(node)) { + this.emitCollectCheck(node, v, segs, errors, failureData); + this.push(`${out}=${v};`); + return; + } + switch (node.k) { + case "sub": + this.emitCollectDelegate(node, v, segs, errors, brk, out); + return; + case "morph": { + const input = this.next("t"); + this.push(`let ${input};`); + const mark = this.markErrors(errors); + this.emitCollectProduce(node.input, v, segs, input, errors, brk, failureData); + this.guardGrowth(errors, mark, brk); + 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){${this.appendError(errors, result)}break ${brk};}`); + if (node.out === undefined) this.push(`${out}=${result};`); + else this.emitCollectProduce(node.out, result, segs, out, errors, brk); + return; + } + case "alias": { + let active = this.#activeAliases; + if (active === undefined) { + active = new Set(); + this.#activeAliases = active; + } + if (active.has(node)) { + this.emitCollectDelegate(node, v, segs, errors, brk, out); + return; + } + active.add(node); + try { + this.emitCollectProduce(node.resolve(), v, segs, out, errors, brk, 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("}"); + const failure = `UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))})`; + this.push(`if(!${ok}){${this.appendError(errors, failure)}break ${brk};}`); + return; + } + case "array": { + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}break ${brk};}`, + ); + if (node.min !== undefined) { + this.push( + `if(${v}.length<${node.min}){${this.appendError( + errors, + this.error(segs, `at least length ${node.min}`, `${v}.length`), + )}break ${brk};}`, + ); + } + if (node.max !== undefined) { + this.push( + `if(${v}.length>${node.max}){${this.appendError( + errors, + this.error(segs, `at most length ${node.max}`, `${v}.length`), + )}break ${brk};}`, + ); + } + const array = this.next("a"); + const index = this.next("i"); + const input = this.next("x"); + const element = this.next("t"); + const label = this.next("L"); + this.push(`const ${array}=new Array(${v}.length);`); + this.push( + `for(let ${index}=0;${index}<${v}.length;${index}++){const ${input}=${v}[${index}];let ${element};${label}:{`, + ); + this.emitCollectProduce(node.el, input, [...segs, { d: index }], element, errors, label); + this.push(`${array}[${index}]=${element};}}`); + this.push(`${out}=${array};`); + return; + } + case "tuple": { + const { postfixStart, prefixCount, requiredPrefix } = this.emitTupleShape( + node, + v, + segs, + errors, + brk, + 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 itemSegs: PathSeg[] = [...segs, { s: index }]; + const input = `${v}[${index}]`; + const output = `${tuple}[${index}]`; + const label = this.next("L"); + if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); + this.push(`${label}:{`); + if (hasMorph(item.val)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(item.val, input, itemSegs, temporary, errors, label); + this.push(`${output}=${temporary};`); + } else { + this.emitCollectCheck(item.val, input, itemSegs, errors); + } + this.push("}"); + if (index >= requiredPrefix) { + if (item.hasDefault) { + this.push("}else{"); + this.emitDefaultFill(item.val, item.def, item.defFactory === true, output, itemSegs, errors); + this.push("}"); + } else { + this.push("}"); + } + } + } + if (node.variadic !== undefined) { + const index = this.next("i"); + const input = this.next("x"); + const label = this.next("L"); + this.push( + `for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){const ${input}=${v}[${index}];${label}:{`, + ); + if (hasMorph(node.variadic)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(node.variadic, input, [...segs, { d: index }], temporary, errors, label); + this.push(`${tuple}[${index}]=${temporary};`); + } else { + this.emitCollectCheck(node.variadic, input, [...segs, { d: index }], errors); + } + this.push("}}"); + } + for (let index = 0; index < node.postfix.length; index++) { + const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; + const input = `${v}[${inputIndex}]`; + const item = node.postfix[index]; + const label = this.next("L"); + this.push(`${label}:{`); + if (hasMorph(item)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(item, input, [...segs, { d: inputIndex }], temporary, errors, label); + this.push(`${tuple}[${inputIndex}]=${temporary};`); + } else { + this.emitCollectCheck(item, input, [...segs, { d: inputIndex }], errors); + } + this.push("}"); + } + this.push(`${out}=${tuple};`); + return; + } + case "object": { + if ( + node.patternIndexes !== undefined || + node.symbolIndex !== undefined || + node.props.some(prop => typeof prop.key === "symbol") + ) { + this.emitCollectDelegate(node, v, segs, errors, brk, out); + return; + } + this.push( + `if(typeof ${v}!=="object"||${v}===null){${this.appendError( + errors, + this.error(segs, "an object", failureData), + )}break ${brk};}`, + ); + const object = this.next("o"); + const fresh = node.extras === "delete" && node.index === undefined; + this.push(fresh ? `const ${object}={};` : `const ${object}={...${v}};`); + for (const prop of node.props) { + const present = `${this.lit(prop.key)} in ${v}`; + const propSegs: PathSeg[] = [...segs, { s: prop.key }]; + const input = this.access(v, prop.key); + const output = this.access(object, prop.key); + const label = this.next("L"); + this.push(`if(!(${present})){`); + if (prop.hasDefault) { + this.emitDefaultFill(prop.val, prop.def, prop.defFactory === true, output, propSegs, errors); + } else if (!prop.opt) { + this.push(this.appendError(errors, this.error(propSegs, expectedOf(prop.val), "M"))); + } + this.push(`}else{${label}:{`); + if (hasMorph(prop.val)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(prop.val, input, propSegs, temporary, errors, label); + this.push(`${output}=${temporary};`); + } else { + this.emitCollectCheck(prop.val, input, propSegs, errors); + if (fresh) this.push(`${output}=${input};`); + } + this.push("}}"); + } + if (node.index !== undefined) { + const key = this.next("k"); + const label = this.next("L"); + this.push(`for(const ${key} in ${v})if(own.call(${v},${key})){${label}:{`); + if (hasMorph(node.index)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(node.index, `${v}[${key}]`, [...segs, { d: key }], temporary, errors, label); + this.push(`${object}[${key}]=${temporary};`); + } else { + this.emitCollectCheck(node.index, `${v}[${key}]`, [...segs, { d: key }], errors); + } + this.push("}}"); + } else if (node.extras === "reject") { + const key = this.next("k"); + this.push( + `for(const ${key} in ${v})if(own.call(${v},${key})&&!(${this.declaredCheck(node.props, key)})){${this.appendError( + errors, + this.error([...segs, { d: key }], "removed", `${v}[${key}]`), + )}}`, + ); + } + if (node.extras === "reject") { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.declaredCheck(node.props, symbol)})){${this.appendError(errors, this.error([...segs, { d: symbol }], "removed", `${v}[${symbol}]`))}}`, + ); + } + this.push(`${out}=${object};`); + return; + } + case "intersection": { + const current = this.next("t"); + this.push(`let ${current}=${v};`); + const mark = this.markErrors(errors); + for (let index = 0; index < node.members.length; index++) { + if (index > 0) this.guardGrowth(errors, mark, brk); + const member = node.members[index]; + if (hasMorph(member)) this.emitCollectProduce(member, current, segs, current, errors, brk); + else this.emitCollectCheck(member, current, segs, errors); + } + this.push(`${out}=${current};`); + return; + } + default: + this.emitCollectDelegate(node, v, segs, errors, brk, out); + } + } + + build(ir: IR): (value: unknown) => unknown { + const errors = this.next("e"); + let ret: string; + if (hasMorph(ir)) { + const label = this.next("L"); + this.push(`let ${errors};let o;${label}:{`); + this.emitCollectProduce(ir, "v", [], "o", errors, label); + this.push("}"); + ret = "o"; + } else { + this.push(`let ${errors};`); + this.emitCollectCheck(ir, "v", [], errors); + ret = "v"; + } + this.push(`if(${errors}!==undefined)return ${errors};`); + const src = `return function(v){${this.#lines.join("")}return ${ret}}`; + const make = new Function("R", "AE", "M", "PF", "UF", "MC", "own", "MD", src) as ( + refs: unknown[], + ae: typeof OmpErrors, + m: typeof MISSING, + pf: typeof prefixErrors, + uf: typeof unionFail, + mc: typeof CompiledMorphContext, + ownFn: typeof own, + md: typeof materializeDefault, + ) => (value: unknown) => unknown; + return make( + this.#refs, + OmpErrors, + MISSING, + prefixErrors, + unionFail, + CompiledMorphContext, + own, + materializeDefault, + ); + } + + emitAllows(node: IR, v: string): void { + switch (node.k) { + case "array": { + const array = this.next("a"); + const index = this.next("i"); + this.push(`const ${array}=${v};if(!Array.isArray(${array}))return false;`); + if (node.min !== undefined) this.push(`if(${array}.length<${node.min})return false;`); + if (node.max !== undefined) this.push(`if(${array}.length>${node.max})return false;`); + this.push(`for(let ${index}=0;${index}<${array}.length;${index}++){`); + this.emitAllows(node.el, `${array}[${index}]`); + this.push("}"); + return; + } + case "object": { + const object = this.next("o"); + this.push(`const ${object}=${v};if(typeof ${object}!=="object"||${object}===null)return false;`); + for (const prop of node.props) { + const value = this.next("p"); + const present = `${this.lit(prop.key)} in ${object}`; + this.push(`const ${value}=${this.access(object, prop.key)};`); + if (prop.opt || prop.hasDefault) { + if (rejectsUndefined(prop.val)) { + this.push(`if(${value}!==undefined){`); + this.emitAllows(prop.val, value); + this.push(`}else if(${present})return false;`); + } else { + this.push(`if(${present}){`); + this.emitAllows(prop.val, value); + this.push("}"); + } + } else { + if (!rejectsUndefined(prop.val)) this.push(`if(!(${present}))return false;`); + this.emitAllows(prop.val, value); + } + } + const stringKey = this.next("k"); + if (node.index !== undefined) { + this.push(`for(const ${stringKey} in ${object}){if(!own.call(${object},${stringKey}))continue;`); + this.emitAllows(node.index, `${object}[${stringKey}]`); + this.push("}"); + } + if (node.patternIndexes !== undefined) { + for (const pattern of node.patternIndexes) { + this.push( + `for(const ${stringKey} in ${object})if(own.call(${object},${stringKey})&&(${this.predicate(pattern.key, stringKey)})&&!(${this.predicate(pattern.val, `${object}[${stringKey}]`)}))return false;`, + ); + } + } + if (node.symbolIndex !== undefined) { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${object})){if(!Object.prototype.propertyIsEnumerable.call(${object},${symbol}))continue;`, + ); + this.emitAllows(node.symbolIndex, `${object}[${symbol}]`); + this.push("}"); + } + if (node.extras === "reject") { + const patternMatch = + node.patternIndexes?.map(pattern => `(${this.predicate(pattern.key, stringKey)})`).join("||") ?? + "false"; + if (node.index === undefined) { + this.push( + `for(const ${stringKey} in ${object})if(own.call(${object},${stringKey})&&!(${this.declaredCheck(node.props, stringKey)})&&!(${patternMatch}))return false;`, + ); + } + if (node.symbolIndex === undefined) { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${object}))if(Object.prototype.propertyIsEnumerable.call(${object},${symbol})&&!(${this.declaredCheck(node.props, symbol)}))return false;`, + ); + } + } + return; + } + case "union": { + const sources: string[] = []; + for (const member of node.members) { + if (!isPrimitiveLiteral(member)) break; + const source = litSource(member.v); + if (source === undefined) break; + sources.push(source); + } + if (sources.length === node.members.length && sources.length >= 4) { + this.push( + `switch(${v}){${sources.map(source => `case ${source}:`).join("")}break;default:return false;}`, + ); + return; + } + this.push(`if(!(${this.predicate(node, v)}))return false;`); + return; + } + default: + this.push(`if(!(${this.predicate(node, v)}))return false;`); + } + } + + buildAllows(ir: IR): (value: unknown) => value is unknown { + this.emitAllows(ir, "v"); + const src = `return function(v){${this.#lines.join("")}return true}`; + const make = new Function("R", "AE", "own", src) as ( + refs: unknown[], + ae: typeof OmpErrors, + ownFn: typeof own, + ) => (value: unknown) => value is unknown; + return make(this.#refs, OmpErrors, 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, path?: PropertyKey[]) => unknown; +} + +/** Cached interpreter closure for recursive aliases and predicate-only fallbacks. */ +function boundWalk(node: IR): (value: unknown, path?: PropertyKey[]) => unknown { + const tagged = node as IR & WalkTagged; + let fn = tagged[kWalk]; + if (!fn) { + fn = (value: unknown, path?: PropertyKey[]) => walk(node, value, path); + tagged[kWalk] = fn; + } + return fn; +} + +function resolvedRoot(ir: IR): IR { + return ir.k === "alias" ? ir.resolve() : ir; +} + +const compiledCache = new WeakMap<IR, (value: unknown) => unknown>(); +const allowsCache = new WeakMap<IR, (value: unknown) => value is unknown>(); + +/** Compile `ir` into a specialized validator. */ +export function compile(ir: IR): (value: unknown) => unknown { + const root = resolvedRoot(ir); + const validator = compiledCache.get(root); + if (validator === undefined) { + // Publish a deferred wrapper before building: recursive schemas re-enter + // compile() for the same root mid-build (e.g. an alias element inside an + // array), and each re-entry must reuse this build instead of starting a + // fresh one forever. The wrapper resolves to the built validator by call + // time; the interpreter is a safety net that never triggers post-build. + let built: ((value: unknown) => unknown) | undefined; + compiledCache.set(root, value => (built === undefined ? walk(root, value) : built(value))); + built = new Builder().build(root); + compiledCache.set(root, built); + return built; + } + return validator; +} + +/** Compile `ir` into an allocation-free boolean validator. */ +export function compileAllows(ir: IR): (value: unknown) => value is unknown { + const root = resolvedRoot(ir); + const validator = allowsCache.get(root); + if (validator === undefined) { + let built: ((value: unknown) => value is unknown) | undefined; + allowsCache.set(root, ((value: unknown) => + built === undefined ? !(walk(root, value) instanceof OmpErrors) : built(value)) as ( + value: unknown, + ) => value is unknown); + built = new Builder().buildAllows(root); + allowsCache.set(root, built); + return built; + } + 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.emitCollectProduce(root, "v", [], "o", "e", "L0"); + return `function(v){/* refs elided */return o}`; + } + builder.emitCollectCheck(root, "v", [], "e"); + 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..854e11148 --- /dev/null +++ b/packages/omptype/src/errors.ts @@ -0,0 +1,446 @@ +/** + * 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 propString: string; + readonly data: unknown; + readonly expected: string; + readonly actual: string; + readonly problem: string; + readonly description: string; + readonly rule?: unknown; +} + +/** Per-schema overrides for validation error text. */ +export interface ErrorConfig { + readonly expected?: string | ((context: ErrorContext) => string); + readonly actual?: string | ((data: unknown) => string); + readonly problem?: string | ((context: ErrorContext) => string); + readonly message?: string | ((context: ErrorContext) => string); + /** Internal: custom predicate expectations display the offending value rather than its domain. */ + readonly preserveActual?: boolean; +} + +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; + } + /** Prefix this failure when a nested schema delegates validation. */ + prefix(key: PropertyKey): this { + this.path.unshift(key); + return this; + } + + /** Apply schema-local formatting to this failure. */ + configure(config: ErrorConfig): this { + this.#config = { ...this.#config, ...config }; + return this; + } + + /** Stable category for programmatic error handling. */ + get code(): string { + return errorCode(this.#rawExpected, this.data); + } + + #context(expected: string, actual: string, problem = ""): ErrorContext { + const { description, rule } = describeExpectation(this.#rawExpected); + return { + code: this.code, + path: this.path, + propString: formatPath(this.path), + data: this.data, + expected, + actual, + problem, + description, + ...(rule === undefined ? {} : { rule }), + }; + } + + /** Human-readable expectation, including a configured override. */ + get expected(): string { + const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code); + const parts = this.#rawExpected.split(" or "); + const fallback = + parts.length < 3 ? this.#rawExpected : `${parts.slice(0, -1).join(", ")} or ${parts[parts.length - 1]}`; + return format(this.#config?.expected, this.#context(fallback, actual), fallback); + } + + /** Short description of the received value, e.g. `"a number"` or `"missing"`. */ + get actual(): string { + const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code); + const override = this.#config?.actual; + return typeof override === "function" ? override(this.data) : (override ?? actual); + } + + /** Path-less problem statement: `must be <expected> (was <actual>)`. */ + get problem(): string { + const expected = this.expected; + const actual = this.actual; + const fallback = + this.data === MISSING + ? `must be ${expected} (was missing)` + : actual === "" + ? `must be ${expected}` + : `must be ${expected} (was ${actual})`; + return format(this.#config?.problem, this.#context(expected, actual, fallback), fallback); + } + get message(): string { + const expected = this.expected; + const actual = this.actual; + const problem = this.problem; + const at = this.path.length === 0 ? "" : `${formatPath(this.path)} `; + const messageActual = this.#config?.actual === undefined ? describeKind(this.data) : actual; + return format(this.#config?.message, this.#context(expected, messageActual, 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 stringifyValue(data: object): string { + const seen = new WeakSet<object>(); + return ( + JSON.stringify(data, (_key, value: unknown) => { + if (typeof value !== "object" || value === null) return value; + if (seen.has(value)) return "(cycle)"; + seen.add(value); + return value; + }) ?? "an object" + ); +} + +function describeValue(data: unknown, code: string): string { + if (data === MISSING) return "missing"; + if (code === "undeclared") return ""; + if (code === "referenceSame") return ""; + if (code === "domain") return describeKind(data); + 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 stringifyValue(data); + case "function": + return "a function"; + default: + return "a symbol"; + } +} + +function describeKind(data: unknown): string { + if (data === MISSING) return "missing"; + if (typeof data === "number" && Number.isNaN(data)) return "NaN"; + if (data === null) return "null"; + if (Array.isArray(data)) return "an object"; + switch (typeof data) { + case "string": + return "a string"; + case "number": + return "a number"; + case "bigint": + return "a bigint"; + case "boolean": + return "boolean"; + case "undefined": + return "undefined"; + case "object": { + const ctor = Object.getPrototypeOf(data)?.constructor; + return typeof ctor?.name === "string" && ctor.name !== "Object" ? ctor.name : "an object"; + } + case "function": + return "a function"; + default: + return "a symbol"; + } +} + +function describeExpectation(expected: string): { description: string; rule?: unknown } { + const divisor = /divisible by (\d+(?:\.\d+)?)/.exec(expected); + if (divisor) { + const rule = Number(divisor[1]); + return { description: rule === 2 ? "even" : `divisible by ${rule}`, rule }; + } + return { description: expected.replace(/^(?:an?|the) /, "") }; +} + +function formatPath(path: readonly PropertyKey[]): string { + let out = ""; + for (const key of path) { + if (typeof key === "number") out += `[${key}]`; + else if (typeof key === "symbol") out += `[${String(key)}]`; + else out += out.length === 0 ? key : `.${key}`; + } + return out; +} + +function errorCode(expected: string, data: unknown): string { + if (data === MISSING) return "required"; + if (expected === "removed") return "undeclared"; + if (expected.includes("serialized to the same value")) return "referenceSame"; + if (expected.includes("divisible by") || expected.includes("integer")) return "divisor"; + if ( + expected === "true" || + expected === "false" || + expected === "null" || + expected === "undefined" || + expected === "NaN" || + expected === "Infinity" || + expected === "-Infinity" || + /^-?\d+(?:\.\d+)?n?$/.test(expected) || + (expected.includes(" or ") && + expected.split(" or ").every(part => /^".*"$|^-?\d+(?:\.\d+)?n?$|^(?:true|false|null|undefined)$/.test(part))) + ) { + return "unit"; + } + if ( + expected.includes("at least") || + expected.includes("more than") || + expected.includes("timestamp after") || + expected === "non-negative" || + expected === "positive" + ) { + return "min"; + } + if ( + expected.includes("at most") || + expected.includes("less than") || + expected.includes("timestamp before") || + expected === "non-positive" || + expected === "negative" + ) { + return "max"; + } + if ( + expected.includes("matching") || + expected.includes("format") || + expected.includes("email") || + expected.includes("parsable") || + expected.includes("only digits") + ) { + return "pattern"; + } + if (expected.includes("predicate") || expected.includes("satisfying") || expected.includes("according to")) + return "predicate"; + if (expected.startsWith('"') || expected.startsWith("the date ")) return "unit"; + if (expected === "an Error" || expected === "a Date" || expected.startsWith("an instance of ")) return "domain"; + if ( + (expected.includes(" or ") && !expected.includes("IPv")) || + expected.endsWith(" instance") || + expected.startsWith("a number representing") || + [ + "a string", + "a number", + "a bigint", + "a symbol", + "boolean", + "an object", + "an array", + "a tuple", + "undefined", + "null", + "unknown", + "never", + ].includes(expected) + ) + return "domain"; + return "predicate"; +} + +/** + * Validation failure result. The common single-error case remains lazy; + * traversal only materializes an entry array when a second error is appended. + */ +type StoredPath = PropertyKey[] | PropertyKey | undefined; + +export class OmpErrors implements Iterable<OmpError> { + #path: StoredPath; + #expected: string; + #data: unknown; + #entry: OmpError | undefined; + #entries: OmpError[] | undefined; + #config: ErrorConfig | undefined; + #separator = "\n"; + + constructor(path: StoredPath, expected: string, data: unknown, config?: ErrorConfig) { + this.#path = path; + this.#expected = expected; + this.#data = data; + this.#config = config; + } + + get length(): number { + return this.#entries?.length ?? 1; + } + + get 0(): OmpError { + return this.#entries?.[0] ?? 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; + } + + /** Append all failures from `other`, preserving traversal order. */ + append(other: OmpErrors): this { + this.#entries ??= [this.#getEntry()]; + const entries = this.#entries; + for (const entry of other) entries.push(entry); + return this; + } + + /** Prefix every failure path with `key` when nesting sub-schemas. */ + prefix(key: PropertyKey): this { + if (this.#entries) { + for (const entry of this.#entries) entry.prefix(key); + } else { + 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 failures. */ + configure(config: ErrorConfig): this { + if (this.#entries) { + for (const entry of this.#entries) entry.configure(config); + } else { + this.#config = { ...this.#config, ...config }; + this.#entry = undefined; + } + return this; + } + + get byPath(): Readonly<Record<string, OmpError>> { + const result: Record<string, OmpError> = {}; + for (const entry of this) result[entry.path.map(String).join(".")] = entry; + return result; + } + + map<result>(fn: (error: OmpError, index: number, errors: OmpErrors) => result): result[] { + const result: result[] = []; + let index = 0; + for (const entry of this) result.push(fn(entry, index++, this)); + return result; + } + + filter(fn: (error: OmpError, index: number, errors: OmpErrors) => unknown): OmpError[] { + const result: OmpError[] = []; + let index = 0; + for (const entry of this) { + if (fn(entry, index++, this)) result.push(entry); + } + return result; + } + + *[Symbol.iterator](): IterableIterator<OmpError> { + if (this.#entries) { + yield* this.#entries; + } else { + yield this.#getEntry(); + } + } + + /** @internal Render multiple branch failures as alternatives rather than independent failures. */ + asAlternatives(): this { + this.#separator = " or "; + return this; + } + get summary(): string { + const entries = [...this]; + if ( + this.#separator === "\n" && + entries.length > 1 && + entries.every( + entry => + Object.is(entry.data, entries[0].data) && + entry.path.length === entries[0].path.length && + entry.path.every((key, index) => key === entries[0].path[index]), + ) + ) { + const at = formatPath(entries[0].path); + const actual = typeof entries[0].data === "string" ? JSON.stringify(entries[0].data) : String(entries[0].data); + return `${at}${at === "" ? "" : " "}(${actual}) must be...\n${entries.map(entry => ` ◦ ${entry.expected}`).join("\n")}`; + } + return entries.map(error => error.message).join(this.#separator); + } + + toString(): string { + return this.summary; + } + + 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/from-json-schema.ts b/packages/omptype/src/from-json-schema.ts new file mode 100644 index 000000000..9815494e0 --- /dev/null +++ b/packages/omptype/src/from-json-schema.ts @@ -0,0 +1,231 @@ +/** + * JSON Schema → omptype importer. + * + * Rebuilds a callable schema from JSON Schema documents — the inverse of + * `Type.toJsonSchema()` for the dialect omptype (and its Zod/TypeBox + * adapters) emits: draft-07 and draft-2020-12 structural keywords, string + * formats, `$defs`/`definitions` references (including recursion), enums, + * and composition via `anyOf`/`oneOf`/`allOf`. + * + * Unknown or non-structural keywords are ignored, matching lenient importer + * behavior: the result validates every constraint the schema can express in + * omptype IR and passes the rest through. + */ +import { OmpTypeError } from "./errors"; +import { type EmbeddableSchema, type IR, IR_BRAND, type PropIR, type TupleItemIR } from "./ir"; +import { keywordIR, patternIR } from "./keywords"; +import { type BaseType, type } from "./type"; + +type JsonSchema = Record<string, unknown>; + +/** `format` values lowered to built-in keyword validators. */ +const FORMAT_KEYWORDS: Record<string, string> = { + email: "string.email", + uuid: "string.uuid", + "date-time": "string.date.iso", + date: "string.date.iso", + ipv4: "string.ip.v4", + ipv6: "string.ip.v6", + regex: "string.regex", +}; + +class Importer { + readonly #root: JsonSchema; + readonly #aliases = new Map<string, IR>(); + + constructor(root: JsonSchema) { + this.#root = root; + } + + resolveRef(ref: string): IR { + const cached = this.#aliases.get(ref); + if (cached !== undefined) return cached; + + let target: unknown; + if (ref === "#") { + target = this.#root; + } else { + const defsMatch = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref); + if (defsMatch === null) throw new OmpTypeError(`unsupported $ref: ${ref}`); + const defs = this.#root[defsMatch[1]]; + target = typeof defs === "object" && defs !== null ? (defs as JsonSchema)[defsMatch[2]] : undefined; + if (target === undefined) throw new OmpTypeError(`unresolved $ref: ${ref}`); + } + + // Register the alias before lowering so recursive references resolve + // to the same node instead of recursing forever. + let lowered: IR | undefined; + const alias: IR = { + k: "alias", + name: ref, + resolve: () => { + lowered ??= this.lower(target); + return lowered; + }, + }; + this.#aliases.set(ref, alias); + return alias; + } + + lower(schema: unknown): IR { + if (schema === true) return { k: "unknown" }; + if (schema === false) return { k: "never" }; + if (typeof schema !== "object" || schema === null) { + throw new OmpTypeError("JSON Schema nodes must be booleans or objects"); + } + const node = schema as JsonSchema; + const desc = typeof node.description === "string" ? node.description : undefined; + const ir = this.#lowerNode(node); + return desc === undefined ? ir : { ...ir, desc }; + } + + #lowerNode(node: JsonSchema): IR { + if (typeof node.$ref === "string") return this.resolveRef(node.$ref); + + if (Array.isArray(node.enum)) { + const members = node.enum.map((v): IR => ({ k: "lit", v })); + return members.length === 1 ? members[0] : { k: "union", members }; + } + if ("const" in node) return { k: "lit", v: node.const }; + + const branches = node.anyOf ?? node.oneOf; + if (Array.isArray(branches)) { + return { k: "union", members: branches.map(branch => this.lower(branch)) }; + } + if (Array.isArray(node.allOf)) { + const members = node.allOf.map(branch => this.lower(branch)); + return members.length === 1 ? members[0] : { k: "intersection", members }; + } + if (typeof node.not === "object" && node.not !== null && Object.keys(node.not).length === 0) { + return { k: "never" }; + } + + if (Array.isArray(node.type)) { + const members = node.type.map(t => this.#lowerTyped(node, String(t))); + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (typeof node.type === "string") return this.#lowerTyped(node, node.type); + + // No explicit type: infer from structural keywords, else accept anything. + if (node.properties !== undefined || node.required !== undefined) return this.#lowerObject(node); + if (node.items !== undefined || node.prefixItems !== undefined) return this.#lowerArray(node); + return { k: "unknown" }; + } + + #lowerTyped(node: JsonSchema, kind: string): IR { + switch (kind) { + case "null": + return { k: "null" }; + case "boolean": + return { k: "boolean" }; + case "string": + return this.#lowerString(node); + case "number": + case "integer": { + const ir: IR = { k: "number" }; + if (kind === "integer") ir.int = true; + if (typeof node.minimum === "number") ir.min = node.minimum; + if (typeof node.maximum === "number") ir.max = node.maximum; + if (typeof node.exclusiveMinimum === "number") { + ir.min = node.exclusiveMinimum; + ir.xmin = true; + } + if (typeof node.exclusiveMaximum === "number") { + ir.max = node.exclusiveMaximum; + ir.xmax = true; + } + if (typeof node.multipleOf === "number") ir.divisor = node.multipleOf; + return ir; + } + case "object": + return this.#lowerObject(node); + case "array": + return this.#lowerArray(node); + default: + throw new OmpTypeError(`unsupported JSON Schema type: ${kind}`); + } + } + + #lowerString(node: JsonSchema): IR { + const base: IR = { k: "string" }; + if (typeof node.minLength === "number") base.min = node.minLength; + if (typeof node.maxLength === "number") base.max = node.maxLength; + if (node.format === "uri" || node.format === "url") base.url = true; + + const members: IR[] = []; + if (typeof node.format === "string") { + const keyword = FORMAT_KEYWORDS[node.format]; + if (keyword !== undefined) { + const formatIR = keywordIR(keyword); + if (formatIR !== undefined) members.push(formatIR); + } + } + if (typeof node.pattern === "string") members.push(patternIR(new RegExp(node.pattern))); + + if (members.length === 0) return base; + if (base.min !== undefined || base.max !== undefined || base.url) members.unshift(base); + return members.length === 1 ? members[0] : { k: "intersection", members }; + } + + #lowerObject(node: JsonSchema): IR { + const required = new Set(Array.isArray(node.required) ? node.required.map(String) : []); + const props: PropIR[] = []; + if (typeof node.properties === "object" && node.properties !== null) { + for (const [key, value] of Object.entries(node.properties)) { + const prop: PropIR = { key, opt: !required.has(key), val: this.lower(value) }; + if (typeof value === "object" && value !== null && "default" in value) { + prop.hasDefault = true; + prop.def = (value as JsonSchema).default; + prop.opt = true; + } + props.push(prop); + } + } + const extra = node.additionalProperties; + return { + k: "object", + props, + extras: extra === false ? "reject" : "keep", + ...(typeof extra === "object" && extra !== null ? { index: this.lower(extra) } : {}), + }; + } + + #lowerArray(node: JsonSchema): IR { + if (Array.isArray(node.prefixItems)) { + const minItems = typeof node.minItems === "number" ? node.minItems : node.prefixItems.length; + const prefix: TupleItemIR[] = node.prefixItems.map((item, index) => ({ + val: this.lower(item), + opt: index >= minItems, + })); + return { + k: "tuple", + prefix, + postfix: [], + ...(node.items !== undefined && node.items !== false ? { variadic: this.lower(node.items) } : {}), + }; + } + const ir: IR = { k: "array", el: node.items === undefined ? { k: "unknown" } : this.lower(node.items) }; + if (typeof node.minItems === "number") ir.min = node.minItems; + if (typeof node.maxItems === "number") ir.max = node.maxItems; + return ir; + } +} + +/** + * Build a callable omptype schema from a JSON Schema document. + * + * # Errors + * Throws {@link OmpTypeError} on malformed nodes, unresolvable `$ref`s, or + * types omptype cannot represent. + */ +export function fromJsonSchema(schema: unknown): BaseType { + const importer = new Importer(typeof schema === "object" && schema !== null ? (schema as JsonSchema) : {}); + const embedded: EmbeddableSchema = { + [IR_BRAND]: true, + ir: importer.lower(schema), + hasSteps: false, + hasDefault: false, + run: value => value, + }; + return type.raw(embedded); +} diff --git a/packages/omptype/src/index.ts b/packages/omptype/src/index.ts new file mode 100644 index 000000000..b8e6c3627 --- /dev/null +++ b/packages/omptype/src/index.ts @@ -0,0 +1,13 @@ +/** + * 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 "./from-json-schema"; +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..779ef2805 --- /dev/null +++ b/packages/omptype/src/infer.ts @@ -0,0 +1,442 @@ +/** Type-level input and output inference for definitions accepted by omptype. */ + +type Whitespace = " " | "\n" | "\r" | "\t"; + +type TrimLeft<s extends string> = s extends `${Whitespace}${infer rest}` ? TrimLeft<rest> : s; +type TrimRight<s extends string> = s extends `${infer rest}${Whitespace}` ? TrimRight<rest> : s; +type Trim<s extends string> = TrimLeft<TrimRight<s>>; + +/** + * Flat keyword lookup. An indexed access is one instantiation level, unlike a + * nested conditional chain — this sits under every string-DSL property, so its + * depth is multiplied by every layer of object nesting above it. + * `never` is intentionally absent: the parser rejects it and the fallback in + * `InferMember` treats a missing entry as "not a primitive". + */ +type ArkAny = ReturnType<typeof JSON.parse>; + +interface PrimitiveMap { + string: string; + "string.url": string; + number: number; + "number.integer": number; + "number.epoch": number; + "number.safe": number; + "number.NaN": number; + "number.Infinity": number; + "number.NegativeInfinity": number; + boolean: boolean; + null: null; + undefined: undefined; + unknown: unknown; + "unknown.any": ArkAny; + any: unknown; + object: object; + bigint: bigint; + symbol: symbol; + Key: PropertyKey; + Date: Date; + Array: unknown[]; + // biome-ignore lint/complexity/noBannedTypes: built-in Function keyword + Function: Function; + RegExp: RegExp; + File: File; + Error: Error; + Set: Set<unknown>; + Map: Map<unknown, unknown>; + WeakSet: WeakSet<WeakKey>; + WeakMap: WeakMap<WeakKey, unknown>; + Promise: Promise<unknown>; + FormData: FormData; + "object.json": unknown; + true: true; + false: false; +} + +type Merge<left, right> = left extends object + ? right extends object + ? Omit<left, keyof right> & right + : never + : never; + +type InferUtility<s extends string> = s extends `Record<${string},${infer value}>` + ? Record<string, InferString<value>> + : s extends `Array<${infer element}>` | `Array.liftFrom<${infer element}>` + ? InferString<element>[] + : s extends `Partial<${infer value}>` + ? Partial<InferString<value>> + : s extends `Required<${infer value}>` + ? Required<InferString<value>> + : s extends `Pick<${infer value},${infer keys}>` + ? Pick<InferString<value>, Extract<InferString<keys>, keyof InferString<value>>> + : s extends `Omit<${infer value},${infer keys}>` + ? Omit<InferString<value>, Extract<InferString<keys>, keyof InferString<value>>> + : s extends `Merge<${infer left},${infer right}>` + ? Merge<InferString<left>, InferString<right>> + : never; + +/** Output types of morph (`.parse`) keywords; `never` when `s` is not one. */ +type InferParse<s extends string> = 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 "object.json.stringify" + ? string + : s extends "FormData.parse" + ? Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> + : s extends "parse.boolean" + ? boolean + : s extends "parse.bigint" + ? bigint + : never; + +/** + * Member inference as a flat false-branch chain: TypeScript tail-evaluates + * chained conditionals in the false position, so this stays at constant + * instantiation depth where the previous `extends infer` ladder nested every + * fallback inside a true branch and accumulated depth per step. + */ +type InferMember<member extends string> = Trim<member> extends infer s extends string ? InferMemberTrimmed<s> : unknown; + +type InferMemberTrimmed<s extends string> = s extends `(${infer inner})` + ? InferString<inner> + : s extends `${infer element}[]` + ? InferMember<element>[] + : 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 + : s extends keyof PrimitiveMap + ? PrimitiveMap[s] + : [InferParse<s>] extends [never] + ? InferUtility<s> extends infer utility + ? [utility] extends [never] + ? InferMemberFallback<s> + : utility + : unknown + : InferParse<s>; + +type InferMemberFallback<s extends string> = s extends `string.${string}` + ? string + : s extends `${string}Date${string}` + ? Date + : s extends `${string}string${string}` + ? string + : s extends `${string}number${string}` + ? number + : unknown; + +/** Split unions without distributing over the accumulated members. */ +type InferUnion<s extends string, result = never> = s extends `${infer head}|${infer tail}` + ? InferUnion<tail, result | InferMember<head>> + : result | InferMember<s>; + +/** Input side of one union member: morph keywords accept their source type. */ +type InferMemberIn<member extends string> = + Trim<member> extends infer s extends string + ? s extends `${string}.parse` | `parse.${string}` + ? string + : InferMemberTrimmed<s> + : unknown; + +/** Split unions on the input side without distributing over accumulated members. */ +type InferUnionIn<s extends string, result = never> = s extends `${infer head}|${infer tail}` + ? InferUnionIn<tail, result | InferMemberIn<head>> + : result | InferMemberIn<s>; + +type HasInlineDefault<s extends string> = s extends `${string}=${string}` + ? s extends `${string}<${string}` | `${string}>${string}` + ? false + : true + : false; + +type WithoutInlineDefault<s extends string> = + HasInlineDefault<s> extends true ? (s extends `${infer base}=${string}` ? Trim<base> : s) : s; + +type InferStringOutput<s extends string> = InferUnion<s>; + +/** String-DSL output inference. */ +export type InferString<s extends string> = + WithoutInlineDefault<Trim<s>> extends infer trimmed extends string + ? trimmed extends `${infer base}?` + ? InferString<base> + : trimmed extends `(${infer inner})[]` + ? InferUnion<inner>[] + : InferStringOutput<trimmed> + : unknown; + +/** String-DSL input inference, preserving the source side of morph keywords. */ +export type InferStringIn<s extends string> = + WithoutInlineDefault<Trim<s>> extends infer trimmed extends string + ? trimmed extends `${infer base}?` + ? InferStringIn<base> + : trimmed extends `(${infer inner})[]` + ? InferUnionIn<inner>[] + : InferUnionIn<trimmed> + : unknown; + +type HasDefault<def> = def extends string + ? HasInlineDefault<def> + : def extends readonly [unknown, "=", unknown] + ? true + : def extends { readonly hasDefault: true } + ? true + : false; + +type DefinitionKeys<def extends object> = Exclude<keyof def, "+" | "[string]" | "...">; + +type IsOptionalProp<key, def> = key extends `${string}?` + ? true + : def extends string + ? Trim<def> extends `${string}?` + ? true + : false + : def extends readonly [unknown, "?"] + ? true + : false; + +type PropName<key extends PropertyKey> = key extends `${infer name}?` ? name : key; +type UnwrapProperty<def> = def extends readonly [infer value, "?" | "=", ...unknown[]] ? value : def; +type Simplify<t> = { [key in keyof t]: t[key] }; + +type OutputRequired<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? HasDefault<def[key]> extends true + ? PropName<key> + : never + : PropName<key>]-?: InferDef<UnwrapProperty<def[key]>>; +}; + +type OutputOptional<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? HasDefault<def[key]> extends true + ? never + : PropName<key> + : never]?: InferDef<UnwrapProperty<def[key]>>; +}; + +type InputRequired<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? never + : HasDefault<def[key]> extends true + ? never + : PropName<key>]-?: InferDefIn<UnwrapProperty<def[key]>>; +}; + +type InputOptional<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? PropName<key> + : HasDefault<def[key]> extends true + ? PropName<key> + : never]?: InferDefIn<UnwrapProperty<def[key]>>; +}; + +/** + * CYCLE SAFETY: when a fluent generic method is called on a schema whose def + * embeds other schemas, TypeScript instantiates `InferDef<def>` while `def` + * is still generic. `"..." extends keyof def` resolves TRUE under permissive + * instantiation (`keyof any` contains every literal), so spread/index members + * cannot be deferred by wrapper conditionals — a bare `InferDef<def["..."]>` + * member re-enters this expansion and instantiates without bound (TS2589). + * Interface members only instantiate when resolved, so routing the recursive + * reference through `DefBox` keeps generic instantiation shallow: it stops at + * a type reference plus an indexed access instead of expanding `InferDef`. + */ +interface DefBox<def> { + readonly out: InferDef<def>; + readonly in: InferDefIn<def>; +} + +type OutputSpread<def extends object> = "..." extends keyof def ? DefBox<def["..."]>["out"] : unknown; +type InputSpread<def extends object> = "..." extends keyof def ? DefBox<def["..."]>["in"] : unknown; +type OutputIndex<def extends object> = "[string]" extends keyof def + ? Record<string, DefBox<def["[string]"]>["out"]> + : unknown; +type InputIndex<def extends object> = "[string]" extends keyof def + ? Record<string, DefBox<def["[string]"]>["in"]> + : unknown; + +type InferObject<def extends object> = "[string]" extends keyof def + ? [DefinitionKeys<def>] extends [never] + ? Record<string, DefBox<def["[string]"]>["out"]> + : Simplify<OutputRequired<def> & OutputOptional<def> & OutputSpread<def>> & OutputIndex<def> + : Simplify<OutputRequired<def> & OutputOptional<def> & OutputSpread<def>>; + +type InferObjectIn<def extends object> = "[string]" extends keyof def + ? [DefinitionKeys<def>] extends [never] + ? Record<string, DefBox<def["[string]"]>["in"]> + : Simplify<InputRequired<def> & InputOptional<def> & InputSpread<def>> & InputIndex<def> + : Simplify<InputRequired<def> & InputOptional<def> & InputSpread<def>>; + +/** Object-literal inference used by fluent composition overloads. */ +export type InferObjectDef<def extends object> = InferObject<def>; + +type InferLiteralDef<def> = def extends { readonly infer: infer output } + ? output + : def extends string + ? InferString<def> + : def extends object + ? InferObjectLiteral<def> + : unknown; + +type InferLiteralDefIn<def> = def extends { readonly inferIn: infer input } + ? input + : def extends string + ? InferStringIn<def> + : def extends object + ? InferObjectLiteralIn<def> + : unknown; + +type LiteralRequired<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? HasDefault<def[key]> extends true + ? PropName<key> + : never + : PropName<key>]-?: InferLiteralDef<UnwrapProperty<def[key]>>; +}; + +type LiteralOptional<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? HasDefault<def[key]> extends true + ? never + : PropName<key> + : never]?: InferLiteralDef<UnwrapProperty<def[key]>>; +}; + +type LiteralRequiredIn<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? never + : HasDefault<def[key]> extends true + ? never + : PropName<key>]-?: InferLiteralDefIn<UnwrapProperty<def[key]>>; +}; + +type LiteralOptionalIn<def extends object> = { + -readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true + ? PropName<key> + : HasDefault<def[key]> extends true + ? PropName<key> + : never]?: InferLiteralDefIn<UnwrapProperty<def[key]>>; +}; + +/** Object-literal inference that unwraps embedded schema values one level deep. */ +export type InferObjectLiteral<def extends object> = Simplify<LiteralRequired<def> & LiteralOptional<def>>; + +/** Input-side object-literal inference (embedded schemas contribute `inferIn`). */ +export type InferObjectLiteralIn<def extends object> = Simplify<LiteralRequiredIn<def> & LiteralOptionalIn<def>>; + +type InstanceOf<ctor> = ctor extends abstract new (...args: never[]) => infer instance ? instance : never; +type SpreadOutput<def> = InferDef<def> extends readonly (infer element)[] ? element[] : never[]; +type SpreadInput<def> = InferDefIn<def> extends readonly (infer element)[] ? element[] : never[]; + +type InferTupleOutput<defs extends readonly unknown[], result extends unknown[] = []> = defs extends readonly [] + ? result + : defs extends readonly ["...", infer spread, ...infer rest] + ? [...result, ...SpreadOutput<spread>, ...InferTupleOutput<rest>] + : defs extends readonly [infer head, ...infer rest] + ? head extends readonly [infer value, "?"] + ? InferTupleOutput<rest, [...result, InferDef<value>?]> + : head extends readonly [infer value, "=", unknown] + ? InferTupleOutput<rest, [...result, InferDef<value>]> + : InferTupleOutput<rest, [...result, InferDef<head>]> + : result; + +type InferTupleInput<defs extends readonly unknown[], result extends unknown[] = []> = defs extends readonly [] + ? result + : defs extends readonly ["...", infer spread, ...infer rest] + ? [...result, ...SpreadInput<spread>, ...InferTupleInput<rest>] + : defs extends readonly [infer head, ...infer rest] + ? head extends readonly [infer value, "?" | "=", ...unknown[]] + ? InferTupleInput<rest, [...result, InferDefIn<value>?]> + : InferTupleInput<rest, [...result, InferDefIn<head>]> + : result; + +/** + * True only for `any` (`1 & any` is `any`; `0 extends any` holds). During + * relation checking TypeScript instantiates these aliases permissively with + * every type parameter replaced by `any`, and under `any` the object branch + * recurses forever (`any["..."]` is `any` again). Cutting `any` off up front + * makes permissive instantiation terminate immediately, mirroring ArkType's + * `anyOrNever` guards. + */ +type IsAny<def> = 0 extends 1 & def ? true : false; + +/** Infer the validated output type produced by a definition. */ +export type InferDef<def = unknown> = + IsAny<def> extends true + ? unknown + : def extends { readonly infer: infer output } + ? output + : def extends string + ? InferString<def> + : def extends RegExp + ? string + : def extends readonly [infer element, "[]"] + ? InferDef<element>[] + : def extends readonly [infer left, "|", infer right] + ? InferDef<left> | InferDef<right> + : def extends readonly [infer left, "&", infer right] + ? InferDef<left> & InferDef<right> + : def extends readonly [unknown, "=>", (...args: never[]) => infer output] + ? output + : def extends readonly [unknown, "|>", infer output] + ? InferDef<output> + : def extends readonly [infer base, ":", unknown] | readonly [infer base, "@", unknown] + ? InferDef<base> + : def extends readonly ["keyof", infer base] + ? keyof InferDef<base> + : def extends readonly ["instanceof", ...infer constructors] + ? InstanceOf<constructors[number]> + : def extends readonly ["===", ...infer values] + ? values[number] + : def extends readonly unknown[] + ? InferTupleOutput<def> + : def extends object + ? InferObject<def> + : unknown; + +/** Infer values accepted before defaults and morphs are applied. */ +export type InferDefIn<def = unknown> = + IsAny<def> extends true + ? unknown + : def extends { readonly inferIn: infer input } + ? input + : def extends string + ? InferStringIn<def> + : def extends RegExp + ? string + : def extends readonly [infer element, "[]"] + ? InferDefIn<element>[] + : def extends readonly [infer left, "|", infer right] + ? InferDefIn<left> | InferDefIn<right> + : def extends readonly [infer left, "&", infer right] + ? InferDefIn<left> & InferDefIn<right> + : def extends readonly [infer input, "=>", unknown] | readonly [infer input, "|>", unknown] + ? InferDefIn<input> + : def extends readonly [infer base, ":", unknown] | readonly [infer base, "@", unknown] + ? InferDefIn<base> + : def extends readonly ["keyof", infer base] + ? keyof InferDefIn<base> + : def extends readonly ["instanceof", ...infer constructors] + ? InstanceOf<constructors[number]> + : def extends readonly ["===", ...infer values] + ? values[number] + : def extends readonly unknown[] + ? InferTupleInput<def> + : def extends object + ? InferObjectIn<def> + : unknown; diff --git a/packages/omptype/src/interp.ts b/packages/omptype/src/interp.ts new file mode 100644 index 000000000..c3be10ef7 --- /dev/null +++ b/packages/omptype/src/interp.ts @@ -0,0 +1,765 @@ +/** + * 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, hasAlias, hasMorph, type IR } from "./ir"; + +const own = Object.prototype.hasOwnProperty; +/** Return an independent runtime value for a prevalidated static default. */ +export function materializeDefault(payload: unknown): unknown { + if (payload === null || typeof payload !== "object") return payload; + if (payload instanceof Date) return new Date(payload); + return structuredClone(payload); +} +let activeVisits: WeakMap<object, Set<IR>> | undefined; +let activeChecks: WeakMap<object, Set<IR>> | undefined; + +/** + * Validate `value` against `ir`; returns output value or `OmpErrors`. + * `path` seeds the traversal location so nested step callbacks observe + * absolute ctx.path values when a compiled parent delegates a subtree; + * resulting error paths are then already absolute. + */ +export function walk(ir: IR, value: unknown, path: PropertyKey[] = []): unknown { + const previousVisits = activeVisits; + const previousChecks = activeChecks; + // Created lazily by visit/checks only when a recursive-alias node is reached. + activeVisits = undefined; + activeChecks = undefined; + try { + return visit(ir, value, path); + } finally { + activeVisits = previousVisits; + activeChecks = previousChecks; + } +} + +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 { + // Cycle guards are only needed when the subtree can revisit nodes through + // recursive aliases; plain schemas skip the WeakMap bookkeeping entirely. + if (typeof v !== "object" || v === null || !hasAlias(ir)) return checkNode(ir, v); + activeChecks ??= new WeakMap(); + const visits = activeChecks; + let visited = visits.get(v); + if (visited?.has(ir)) return true; + if (visited === undefined) { + visited = new Set(); + visits.set(v, visited); + } + visited.add(ir); + try { + return checkNode(ir, v); + } finally { + visited.delete(ir); + } +} + +function checkNode(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 element of v) if (!checks(ir.el, element)) 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) return false; + const rec = v as Record<PropertyKey, unknown>; + 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; + } + for (const key in rec) { + if (!own.call(rec, key)) continue; + if (ir.index !== undefined && !checks(ir.index, rec[key])) return false; + let patternMatched = false; + if (ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (!checks(pattern.key, key)) continue; + patternMatched = true; + if (!checks(pattern.val, rec[key])) return false; + } + } + if ( + ir.extras === "reject" && + ir.index === undefined && + !patternMatched && + !ir.props.some(prop => prop.key === key) + ) { + return false; + } + } + for (const key of Object.getOwnPropertySymbols(rec)) { + if (!Object.prototype.propertyIsEnumerable.call(rec, key)) continue; + if (ir.symbolIndex !== undefined && !checks(ir.symbolIndex, rec[key])) return false; + if (ir.extras === "reject" && ir.symbolIndex === undefined && !ir.props.some(prop => prop.key === key)) { + 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) === true; + } 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 { + if (typeof v !== "object" || v === null || !hasAlias(ir)) return visitFinish(ir, v, path); + activeVisits ??= new WeakMap(); + const visits = activeVisits; + let visited = visits.get(v); + if (visited?.has(ir)) return v; + if (visited === undefined) { + visited = new Set(); + visits.set(v, visited); + } + visited.add(ir); + try { + return visitFinish(ir, v, path); + } finally { + visited.delete(ir); + } +} + +/** Run the node visitor, then apply node-local error configuration. */ +function visitFinish(ir: IR, v: unknown, path: PropertyKey[]): unknown { + const out = visitNode(ir, v, path); + if (!(out instanceof OmpErrors) || ir.cfg === undefined) return out; + for (const error of out) { + if (error.path.length !== path.length) return out; + } + return out.configure(ir.cfg); +} + +function visitNode(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 { + const result = ir.pred(base); + if (result instanceof OmpErrors) return path.length === 0 ? result : prefixAll(result, path); + return result ? 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, path); + 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; + } + } + let targeted: OmpErrors | undefined; + let targetCount = 0; + for (const member of ir.members) { + if (member.k === "sub" || hasMorph(member)) { + const out = visit(member, v, path); + if (!(out instanceof OmpErrors)) return out; + if (kindMatches(unwrapBase(member), v)) { + targetCount++; + targeted ??= out; + } + } + } + if (targetCount === 1 && targeted !== undefined) return targeted; + 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.length); + if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v.length); + const morph = hasMorph(ir.el); + const out = morph ? new Array<unknown>(v.length) : v; + let errors: OmpErrors | undefined; + for (let index = 0; index < v.length; index++) { + path.push(index); + const element = visit(ir.el, v[index], path); + path.pop(); + if (element instanceof OmpErrors) { + if (errors) errors.append(element); + else errors = element; + } else if (morph) { + out[index] = element; + } + } + return errors ?? 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; + let errors: OmpErrors | undefined; + 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) { + if (errors) errors.append(item); + else errors = item; + } else 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; + if (item.defFactory && typeof payload === "function") { + path.push(index); + const resolved = visit(item.val, payload(), path); + path.pop(); + if (resolved instanceof OmpErrors) { + if (errors) errors.append(resolved); + else errors = resolved; + } else { + output[index] = resolved; + } + } else { + output[index] = materializeDefault(payload); + } + } else if (!item.opt) { + path.push(index); + const error = fail(path, expectedOf(item.val), MISSING); + path.pop(); + if (errors) errors.append(error); + else errors = 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) { + if (errors) errors.append(item); + else errors = item; + } else 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) { + if (errors) errors.append(item); + else errors = item; + } else if (morph) { + output[inputIndex] = item; + } + } + return errors ?? output; + } + case "object": { + if (typeof v !== "object" || v === null) return fail(path, "an object", v); + const rec = v as Record<PropertyKey, unknown>; + const morph = hasMorph(ir); + let out: Record<PropertyKey, unknown> | undefined; + let errors: OmpErrors | undefined; + if (morph) { + if ( + ir.extras === "delete" && + ir.index === undefined && + ir.symbolIndex === undefined && + ir.patternIndexes === undefined + ) { + out = {}; + } else { + out = { ...rec }; + } + } + for (const p of ir.props) { + if (!(p.key in rec)) { + if (p.hasDefault && out) { + const payload = p.def; + if (p.defFactory && typeof payload === "function") { + path.push(p.key); + const resolved = visit(p.val, payload(), path); + path.pop(); + if (resolved instanceof OmpErrors) { + if (errors) errors.append(resolved); + else errors = resolved; + } else { + out[p.key] = resolved; + } + } else { + out[p.key] = materializeDefault(payload); + } + continue; + } + if (p.opt || p.hasDefault) continue; + path.push(p.key); + const error = fail(path, expectedOf(p.val), MISSING); + path.pop(); + if (errors) errors.append(error); + else errors = error; + continue; + } + path.push(p.key); + const result = visit(p.val, rec[p.key], path); + path.pop(); + if (result instanceof OmpErrors) { + if (errors) errors.append(result); + else errors = result; + } else if (out) { + out[p.key] = result; + } + } + for (const key in rec) { + if (!own.call(rec, key)) continue; + let indexed = false; + if (ir.index !== undefined) { + indexed = true; + path.push(key); + const result = visit(ir.index, rec[key], path); + path.pop(); + if (result instanceof OmpErrors) { + if (errors) errors.append(result); + else errors = result; + } else if (out) { + out[key] = result; + } + } + if (ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (!checks(pattern.key, key)) continue; + indexed = true; + path.push(key); + const result = visit(pattern.val, rec[key], path); + path.pop(); + if (result instanceof OmpErrors) { + if (errors) errors.append(result); + else errors = result; + } else if (out) { + out[key] = result; + } + } + } + if (ir.extras === "reject" && !indexed && !ir.props.some(prop => prop.key === key)) { + path.push(key); + const error = fail(path, "removed", rec[key]); + path.pop(); + if (errors) errors.append(error); + else errors = error; + } + } + for (const key of Object.getOwnPropertySymbols(rec)) { + if (!Object.prototype.propertyIsEnumerable.call(rec, key)) continue; + if (ir.symbolIndex !== undefined) { + path.push(key); + const result = visit(ir.symbolIndex, rec[key], path); + path.pop(); + if (result instanceof OmpErrors) { + if (errors) errors.append(result); + else errors = result; + } else if (out) { + out[key] = result; + } + } else if (ir.extras === "reject" && !ir.props.some(prop => prop.key === key)) { + path.push(key); + const error = fail(path, "removed", rec[key]); + path.pop(); + if (errors) errors.append(error); + else errors = error; + } + } + return errors ?? out ?? v; + } + case "string": { + if (typeof v !== "string") return fail(path, "a string", v); + if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v.length); + if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v.length); + if (ir.url && !URL.canParse(v)) return fail(path, "a URL string", v); + return v; + } + case "number": { + if (typeof v !== "number" || !Number.isFinite(v)) return fail(path, ir.int ? "an integer" : "a number", v); + let errors: OmpErrors | undefined; + const add = (expected: string): void => { + const error = fail(path, expected, v); + if (errors) errors.append(error); + else errors = error; + }; + if (ir.int && !Number.isInteger(v)) add("an integer"); + if (ir.divisor !== undefined && v % ir.divisor !== 0) add(`a number divisible by ${ir.divisor}`); + if (ir.min !== undefined && (ir.xmin ? v <= ir.min : v < ir.min)) { + add( + ir.min === 0 + ? ir.xmin + ? "positive" + : "non-negative" + : `a number ${ir.xmin ? "more than" : "at least"} ${ir.min}`, + ); + } + if (ir.max !== undefined && (ir.xmax ? v >= ir.max : v > ir.max)) { + add( + ir.max === 0 + ? ir.xmax + ? "negative" + : "non-positive" + : `a number ${ir.xmax ? "less than" : "at most"} ${ir.max}`, + ); + } + return errors ?? v; + } + case "lit": { + if (checks(ir, v)) return v; + if ((typeof ir.v === "object" && ir.v !== null) || typeof ir.v === "function") { + let expected = "the specified reference"; + try { + const serialized = JSON.stringify(ir.v); + if (serialized !== undefined) { + expected = `reference equal to ${serialized}`; + if (typeof v === "object" && v !== null && JSON.stringify(v) === serialized) { + expected += " (serialized to the same value)"; + } + } + } catch { + // Cyclic values still get a useful reference-identity expectation. + } + return fail(path, expected, v); + } + return fail(path, expectedOf(ir), 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 = unwrapBase(member); + 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 member of ir.members) { + const base = unwrapBase(member); + if (!kindMatches(base, v)) continue; + if (best !== undefined) { + best = undefined; + break; + } + best = member; + } + if (best === undefined) { + const discriminated = discriminateFailure(ir.members, v, path); + if (discriminated !== undefined) return discriminated; + } + if (best !== undefined) { + const out = visit(best, v, path); + if (out instanceof OmpErrors) return out; + } + if (ir.members.every(member => unwrapBase(member).k === "object")) { + const branches = ir.members.flatMap(member => { + const result = visit(member, v, path); + return result instanceof OmpErrors ? [[...result]] : []; + }); + if (branches.length !== 0) { + const common = branches[0].filter( + (entry, index, first) => + first.findIndex(candidate => pathsEqual(candidate.path, entry.path)) === index && + branches.every(branch => branch.some(candidate => pathsEqual(candidate.path, entry.path))), + ); + const alternatives: OmpErrors[] = []; + if (common.length !== 0) { + for (const entry of common) { + const expectations = new Set<string>(); + for (const branch of branches) { + for (const candidate of branch) { + if (pathsEqual(candidate.path, entry.path)) { + expectations.add(candidate.expected.endsWith(" instance") ? "an object" : candidate.expected); + } + } + } + alternatives.push( + new OmpErrors(entry.path, [...expectations].join(" or "), entry.data, { preserveActual: true }), + ); + } + } else { + for (const branch of branches) { + for (const entry of branch) { + alternatives.push( + new OmpErrors( + entry.path, + entry.expected.endsWith(" instance") ? "an object" : entry.expected, + entry.data, + { preserveActual: true }, + ), + ); + } + } + } + const combined = alternatives[0]; + for (let index = 1; index < alternatives.length; index++) combined.append(alternatives[index]); + return alternatives.length === 1 ? combined : combined.asAlternatives(); + } + } + return fail(path, expected ?? expectedOf(ir), v); +} + +interface LiteralDiscriminant { + path: PropertyKey[]; + value: unknown; +} + +function unwrapBase(member: IR, seen = new Set<IR>()): IR { + if (seen.has(member)) return member; + seen.add(member); + if (member.k === "sub") return unwrapBase(member.schema.ir, seen); + if (member.k === "alias") return unwrapBase(member.resolve(), seen); + if (member.k === "refine") return unwrapBase(member.base, seen); + return member; +} + +function collectDiscriminants(member: IR, prefix: PropertyKey[] = [], seen = new Set<IR>()): LiteralDiscriminant[] { + if (seen.has(member)) return []; + seen.add(member); + if (member.k === "alias") return collectDiscriminants(member.resolve(), prefix, seen); + if (member.k === "sub") return collectDiscriminants(member.schema.ir, prefix, seen); + if (member.k === "refine") return collectDiscriminants(member.base, prefix, seen); + if (member.k !== "object") return []; + const result: LiteralDiscriminant[] = []; + for (const property of member.props) { + const propertyPath = [...prefix, property.key]; + const value = unwrapBase(property.val); + if (value.k === "lit") result.push({ path: propertyPath, value: value.v }); + else result.push(...collectDiscriminants(property.val, propertyPath, new Set(seen))); + } + return result; +} + +function pathsEqual(left: readonly PropertyKey[], right: readonly PropertyKey[]): boolean { + return left.length === right.length && left.every((key, index) => key === right[index]); +} + +function valueAtPath(value: unknown, path: readonly PropertyKey[]): { present: boolean; value?: unknown } { + let cursor = value; + for (const key of path) { + if ((typeof cursor !== "object" && typeof cursor !== "function") || cursor === null || !(key in cursor)) { + return { present: false }; + } + cursor = (cursor as Record<PropertyKey, unknown>)[key]; + } + return { present: true, value: cursor }; +} + +function discriminateFailure(members: IR[], value: unknown, path: PropertyKey[]): OmpErrors | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const byMember = members.map(member => collectDiscriminants(member)); + const candidates: { path: PropertyKey[]; distinct: number; declared: number }[] = []; + for (const discriminants of byMember) { + for (const discriminant of discriminants) { + if (candidates.some(candidate => pathsEqual(candidate.path, discriminant.path))) continue; + const values: unknown[] = []; + let declared = 0; + for (const branch of byMember) { + const match = branch.find(candidate => pathsEqual(candidate.path, discriminant.path)); + if (match === undefined) continue; + declared++; + if (!values.some(candidate => Object.is(candidate, match.value))) values.push(match.value); + } + if (values.length > 1) candidates.push({ path: discriminant.path, distinct: values.length, declared }); + } + } + candidates.sort((left, right) => right.distinct - left.distinct || right.declared - left.declared); + for (const candidate of candidates) { + const actual = valueAtPath(value, candidate.path); + const exact: IR[] = []; + const defaults: IR[] = []; + const expectedMembers: IR[] = []; + for (let index = 0; index < members.length; index++) { + const discriminant = byMember[index].find(item => pathsEqual(item.path, candidate.path)); + if (discriminant === undefined) { + defaults.push(members[index]); + continue; + } + expectedMembers.push({ k: "lit", v: discriminant.value }); + if (actual.present && Object.is(actual.value, discriminant.value)) exact.push(members[index]); + } + if (!actual.present) { + if (defaults.length !== 0 && defaults.length < members.length) { + return discriminateFailure(defaults, value, path); + } + return fail([...path, ...candidate.path], expectedOf({ k: "union", members: expectedMembers }), undefined); + } + if (exact.length === 0) { + if (defaults.length !== 0) return discriminateFailure(defaults, value, path); + return fail([...path, ...candidate.path], expectedOf({ k: "union", members: expectedMembers }), actual.value); + } + if (exact.length === 1) { + const result = visit(exact[0], value, path); + return result instanceof OmpErrors ? result : undefined; + } + const nested = discriminateFailure(exact, value, path); + if (nested !== undefined) return nested; + } + return undefined; +} + +/** 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..f43c59b17 --- /dev/null +++ b/packages/omptype/src/ir.ts @@ -0,0 +1,1796 @@ +/** + * 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 { type ErrorConfig, 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"); +const kMorphOwner: unique symbol = Symbol("omptype.hasMorphOwner"); +const kAlias: unique symbol = Symbol("omptype.hasAlias"); +const kAliasOwner: unique symbol = Symbol("omptype.hasAliasOwner"); +const kSimple: unique symbol = Symbol("omptype.simple"); +const kSimpleOwner: unique symbol = Symbol("omptype.simpleOwner"); + +interface IRAnalysis { + [kMorph]?: boolean; + [kMorphOwner]?: object; + [kAlias]?: boolean; + [kAliasOwner]?: object; + [kSimple]?: boolean; + [kSimpleOwner]?: object; + /** Node-local metadata used for shallow error formatting. */ + cfg?: ErrorConfig; + /** True when `desc` was derived from the node itself rather than authored via `.describe()`. */ + descAuto?: 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; + /** Output IR of the last `.to(target)` step, when statically known. */ + stepOut?: IR; + /** True when the last pipe step is bare — output shape statically unknown. */ + opaqueOutput?: boolean; + /** `.default()` payload; a function is a factory invoked per fill. */ + defaultValue?: unknown; + hasDefault: boolean; + /** Precomputed output for a non-factory default after validation and morphs. */ + defaultOutput?: unknown; + hasDefaultOutput?: boolean; + /** `.describe()` annotation, emitted into JSON Schema. */ + description?: string; + /** Full validate+morph pipeline (identical to calling the schema). */ + run(value: unknown, path?: readonly PropertyKey[]): 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; + /** True once the default has been validated and static morph output precomputed. */ + defValidated?: 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; + symbolIndex?: IR; + patternIndexes?: { key: IR; val: IR }[]; + extras: Extras; + desc?: string; + } + | { + k: "refine"; + base: IR; + pred: (value: unknown) => boolean | OmpErrors; + expected: string; + json?: Record<string, unknown>; + 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: PropertyKey; + opt: boolean; + val: IR; + /** Default payload (value, or factory when `defFactory`); missing key is filled. */ + def?: unknown; + defFactory?: boolean; + hasDefault?: boolean; + /** True once the default has been validated and static morph output precomputed. */ + defValidated?: 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: "bigint"; v: bigint } + | { t: "date"; v: Date } + | { t: "regex"; v: RegExp } + | { 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 === "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 source = src.slice(i + 2, end).trim(); + const value = /^\d+$/.test(source) ? new Date(Number(source)) : new Date(source); + 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 === "'" || c === '"') { + let j = i + 1; + let value = ""; + for (; j < n && src[j] !== c; j++) { + if (src[j] === "\\") { + j++; + if (j >= n) break; + } + value += src[j]; + } + if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`); + toks.push({ t: "str", v: value }); + i = j + 1; + continue; + } + if (c === "/") { + let j = i + 1; + for (; j < n; j++) { + if (src[j] === "\\") j++; + else if (src[j] === "/") break; + } + if (j >= n) throw new OmpTypeError(`unterminated regular expression in "${src}"`); + let end = j + 1; + while (end < n && /[dgimsuvy]/.test(src[end])) end++; + const source = src.slice(i + 1, j); + const flags = src.slice(j + 1, end); + try { + toks.push({ t: "regex", v: new RegExp(source, flags) }); + } catch { + throw new OmpTypeError(`invalid regular expression "${src.slice(i, end)}"`); + } + i = end; + 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 && /[\w.+-]/.test(src[j])) j++; + const raw = src.slice(i, j); + if (/^-?(?:0|[1-9]\d*)n$/.test(raw) && raw !== "-0n") { + toks.push({ t: "bigint", v: BigInt(raw.slice(0, -1)) }); + } else { + const valid = + /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw) && !Object.is(Number(raw), -0) && String(Number(raw)) === raw; + if (!valid) throw new OmpTypeError(`Malformed number literal '${raw}'`); + toks.push({ t: "num", v: Number(raw) }); + } + 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 (c === "=" && src[i + 1] === "=") { + toks.push({ t: "op", v: "==" }); + i += 2; + 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<string, true> = { "<": true, "<=": true, ">": true, ">=": true }; + +const KEYWORDS: Record<string, () => 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 named scope aliases and, when present, scoped generic invocations. */ +export interface AliasResolver { + (name: string): IR | undefined; + hasGeneric?(name: string): boolean; + generic?(name: string, arguments_: readonly IR[]): IR | undefined; +} + +/** + * Resolvers that only intercept the `this` self-reference. A parse under such + * a resolver of a source with no `this` token is identical to a resolver-free + * parse, so it may read and populate the string-definition cache. + */ +const THIS_ONLY_RESOLVERS = new WeakSet<AliasResolver>(); + +/** Declare that `resolve` only intercepts `this` (see THIS_ONLY_RESOLVERS). */ +export function markThisOnlyResolver(resolve: AliasResolver): void { + THIS_ONLY_RESOLVERS.add(resolve); +} + +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 === "bigint" || t.t === "date" || 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 if (t.t === "id" && t.v === "undefined") def = undefined; + 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.parseIntersection(); + if (!this.#eatOp("|")) return first; + const members = [first, this.parseIntersection()]; + while (this.#eatOp("|")) members.push(this.parseIntersection()); + return { k: "union", members }; + } + + parseIntersection(): IR { + const first = this.parseBounded(); + if (!this.#eatOp("&")) return first; + const members = [first, this.parseBounded()]; + while (this.#eatOp("&")) members.push(this.parseBounded()); + const literal = members.find((member): member is Extract<IR, { k: "lit" }> => member.k === "lit"); + if (literal && typeof literal.v === "number") { + for (const member of members) { + if ( + member.k === "number" && + ((member.int && !Number.isInteger(literal.v)) || + (member.divisor !== undefined && literal.v % member.divisor !== 0) || + (member.min !== undefined && (member.xmin ? literal.v <= member.min : literal.v < member.min)) || + (member.max !== undefined && (member.xmax ? literal.v >= member.max : literal.v > member.max))) + ) { + throw new OmpTypeError("literal is excluded by intersection"); + } + } + return literal; + } + return { k: "intersection", 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" && (t1.v === "<" || 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])) { + throw new OmpTypeError(`left bound requires a corresponding right bound in "${this.#src}"`); + } + if (t2.v === ">" || t2.v === ">=") { + throw new OmpTypeError(`right bound must use < or <= in "${this.#src}"`); + } + 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" && t2.v === "==") { + this.#pos++; + const limit = this.#next(); + if (limit.t !== "num" && limit.t !== "bigint" && limit.t !== "date") { + throw new OmpTypeError(`expected literal after == in "${this.#src}"`); + } + node = applyEquality(node, limit.v, this.#src); + } else if (t2?.t === "op" && CMP[t2.v] && (this.#peek(1)?.t === "num" || this.#peek(1)?.t === "date")) { + this.#pos++; + const limit = this.#next() as Extract<Tok, { t: "num" | "date" }>; + 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) || !Number.isInteger(divisor.v) || divisor.v === 0) + throw new OmpTypeError(`divisor must be a non-zero integer in "${this.#src}"`); + // Copy-on-write: the primary may be a shared node (string-def cache, + // generic arguments); stamping it in place would leak into other schemas. + return { ...node, divisor: Math.abs(divisor.v) }; + } + + /** 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 === "[") { + this.#pos++; + if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`); + node = { k: "array", el: node }; + continue; + } + if (t?.t === "op" && t.v === "#") { + this.#pos++; + const name = this.#next(); + if (name.t !== "id") throw new OmpTypeError(`expected brand name after # in "${this.#src}"`); + continue; + } + break; + } + return node; + } + + #parseGenericArguments(): IR[] { + this.#eatOp("<"); + const arguments_: IR[] = []; + if (this.#eatOp(">")) return arguments_; + for (;;) { + arguments_.push(this.parseUnion()); + if (this.#eatOp(">")) return arguments_; + if (!this.#eatOp(",")) throw new OmpTypeError(`expected ',' or '>' in "${this.#src}"`); + const next = this.#peek(); + if (next?.t === "op" && (next.v === "," || next.v === ">")) { + throw new OmpTypeError(`generic arguments cannot be empty in "${this.#src}"`); + } + } + } + + 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 === "bigint" || t.t === "date") return { k: "lit", v: t.v }; + if (t.t === "regex") return patternIR(t.v); + if (t.t === "id") { + if (t.v === "keyof") { + try { + return keyOf(this.parsePostfix()); + } catch (error) { + if (error instanceof OmpTypeError) throw new OmpTypeError("keyof operand must be an object"); + throw error; + } + } + if (t.v === "Array.liftFrom" && this.#peek()?.t === "op" && this.#peek()?.v === "<") { + this.#pos++; + const element = this.parsePrimary(); + if (!this.#eatOp(">")) throw new OmpTypeError(`expected '>' in "${this.#src}"`); + const array: IR = { k: "array", el: element, desc: "an object" }; + return { + k: "morph", + input: { k: "union", members: [element, array] }, + fn: value => (Array.isArray(value) ? value : [value]), + out: array, + }; + } + if (t.v === "Record" && this.#peek()?.t === "op" && this.#peek()?.v === "<") { + const arguments_ = this.#parseGenericArguments(); + if (arguments_.length !== 2) throw new OmpTypeError("Record requires two arguments"); + return { k: "object", props: [], index: arguments_[1], extras: "keep" }; + } + if (this.#peek()?.t === "op" && this.#peek()?.v === "<" && this.#resolve?.hasGeneric?.(t.v)) { + const arguments_ = this.#parseGenericArguments(); + const instantiated = this.#resolve.generic?.(t.v, arguments_); + if (!instantiated) throw new OmpTypeError(`unknown generic "${t.v}" in "${this.#src}"`); + return instantiated; + } + const scoped = this.#resolve?.(t.v); + const make = KEYWORDS[t.v]; + const keyword = scoped ?? make?.() ?? keywordIR(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<string, ParsedTop>(); + +function isWhitespaceAt(src: string, index: number): boolean { + const code = src.charCodeAt(index); + return code === 32 || (code >= 9 && code <= 13) || (code > 127 && /\s/.test(src[index])); +} + +/** Fast path for the literal unions pervasive in command schemas. */ +function parseLiteralUnion(src: string): IR | undefined { + const members: Extract<IR, { k: "lit" }>[] = []; + let index = 0; + while (index < src.length && isWhitespaceAt(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 && isWhitespaceAt(src, index)) index++; + if (index === src.length) { + const ir: IR = members.length === 1 ? members[0] : { k: "union", members }; + let simple = true; + for (let member = 1; simple && member < members.length; member++) { + for (let previous = 0; previous < member; previous++) { + if (members[previous].k === "lit" && members[previous].v === members[member].v) { + simple = false; + break; + } + } + } + ir[kSimple] = simple; + ir[kSimpleOwner] = ir; + return ir; + } + if (src[index] !== "|") return undefined; + index++; + while (index < src.length && isWhitespaceAt(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<PropertyKey> { + const keys = new Set<PropertyKey>(); + 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<IR>(); + 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, + symbolIndex: right.symbolIndex ?? left.symbolIndex, + patternIndexes: + left.patternIndexes === undefined && right.patternIndexes === undefined + ? undefined + : [...(left.patternIndexes ?? []), ...(right.patternIndexes ?? [])], + 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 === "Array.liftFrom" && generic.args.length === 1) { + const element = parseDef(generic.args[0], resolve); + const array: IR = { k: "array", el: element, desc: "an object" }; + return { + k: "morph", + input: { k: "union", members: [element, array] }, + fn: value => (Array.isArray(value) ? value : [value]), + out: array, + }; + } + if (generic.name === "Record" && generic.args.length === 2) { + return { k: "object", props: [], index: parseDef(generic.args[1], resolve), extras: "keep" }; + } + if ((generic.name === "Extract" || generic.name === "Exclude") && generic.args.length === 2) { + return distributeFilter( + parseDef(generic.args[0], resolve), + parseDef(generic.args[1], resolve), + generic.name === "Extract", + ); + } + 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; +} + +/** + * Subtype comparison lives in `type.ts` (it needs full traversal), so it is + * installed here at module load for the parser's `Extract`/`Exclude` support. + */ +let isAssignable: (source: IR, target: IR) => boolean = () => false; + +/** Install the assignability comparator used by `Extract`/`Exclude`. */ +export function useAssignability(compare: (source: IR, target: IR) => boolean): void { + isAssignable = compare; +} + +/** + * Distribute `base` over its union members, keeping those assignable to + * `target` (`keepAssignable`) or those that are not (`Exclude`). + */ +export function distributeFilter(base: IR, target: IR, keepAssignable: boolean): IR { + const resolved = base.k === "alias" ? base.resolve() : base; + const members = resolved.k === "union" ? resolved.members : [resolved]; + const retained = members.filter(member => isAssignable(member, target) === keepAssignable); + if (retained.length === 0) return { k: "never" }; + return retained.length === 1 ? retained[0] : { k: "union", members: retained }; +} + +/** Parse recurring global DSL fragments once; scoped aliases bypass the cache. */ +function parseRegexExec(src: string): IR | undefined { + if (!src.startsWith("x/")) return undefined; + const end = src.lastIndexOf("/"); + if (end < 2) throw new OmpTypeError(`unterminated regular expression in "${src}"`); + let regex: RegExp; + try { + regex = new RegExp(src.slice(2, end), src.slice(end + 1)); + } catch { + throw new OmpTypeError(`invalid regular expression "${src.slice(1)}"`); + } + return { + k: "morph", + input: patternIR(regex), + fn: (value, context) => { + regex.lastIndex = 0; + return regex.exec(value as string) ?? context.error(`a string matching ${regex}`); + }, + }; +} + +function parseStringDef(src: string, resolve?: AliasResolver): ParsedTop { + const cacheable = resolve === undefined || (!src.includes("this") && THIS_ONLY_RESOLVERS.has(resolve)); + if (cacheable) { + const cached = stringDefCache.get(src); + if (cached) return cached; + } + const pipeIndex = src.indexOf("|>"); + if (pipeIndex >= 0) { + const input = src.slice(0, pipeIndex).trim(); + const output = src.slice(pipeIndex + 2).trim(); + if (input.length === 0 || output.length === 0) { + throw new OmpTypeError(`pipe expression requires operands in "${src}"`); + } + const parsedInput = parseStringDef(input, resolve); + if (parsedInput.hasDefault) { + throw new OmpTypeError(`unexpected pipe expression after default in "${src}"`); + } + const parsed: ParsedTop = { + ir: { + k: "morph", + input: parsedInput.ir, + fn: value => value, + out: parseStringDef(output, resolve).ir, + }, + hasDefault: false, + optional: false, + }; + if (cacheable && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed); + return parsed; + } + let ir = parseLiteralUnion(src) ?? parseRegexExec(src) ?? parseGeneric(src, resolve); + if (ir === undefined && src.startsWith("`") && src.endsWith("`")) { + ir = templateIR(src.slice(1, -1)); + } + + const parsed: ParsedTop = + ir === undefined ? new StrParser(src, resolve).parseTop() : { ir, hasDefault: false, optional: false }; + if (cacheable && 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 "<="; + } +} + +function applyEquality(node: IR, value: number | bigint | Date, src: string): IR { + if (node.k === "union") { + return { k: "union", members: node.members.map(member => applyEquality(member, value, src)) }; + } + if (value instanceof Date) { + if (!acceptsDate(node)) throw new OmpTypeError(`Date equality requires Date in "${src}"`); + return { k: "lit", v: value }; + } + if (node.k === "number" && typeof value === "number") return { k: "lit", v: value }; + if (node.k === "bigint" && typeof value === "bigint") return { k: "lit", v: value }; + if ((node.k === "string" || node.k === "array") && typeof value === "number") { + if (!Number.isInteger(value) || value < 0) { + throw new OmpTypeError(`exact length must be a non-negative integer in "${src}"`); + } + return { ...node, min: value, max: value }; + } + throw new OmpTypeError(`equality literal is incompatible with ${node.k} in "${src}"`); +} + +/** + * Apply `node CMP value` — numeric/string/array ranges or Date bounds. + * Copy-on-write: `node` may be shared (string-def cache, generic arguments, + * resolved aliases), so bounds land on a fresh node, never in place. + */ +function applyBound(node: IR, op: string, value: number | Date, src: string): IR { + if (node.k === "alias") return applyBound(node.resolve(), op, value, src); + if (node.k === "refine" && !(value instanceof Date)) { + return { ...node, base: applyBound(node.base, op, value, src) }; + } + if (node.k === "union") { + const kinds = new Set(node.members.map(boundKind)); + if (kinds.size !== 1) throw new OmpTypeError(`cannot apply one bound to multiple bound kinds in "${src}"`); + return { k: "union", members: node.members.map(member => applyBound(member, op, value, src)) }; + } + if (!(value instanceof Date) && acceptsDate(node)) { + return applyBound(node, op, new Date(value), src); + } + 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") { + const bounded = { ...node }; + switch (op) { + case ">=": + bounded.min = value; + bounded.xmin = false; + break; + case ">": + bounded.min = value; + bounded.xmin = true; + break; + case "<=": + bounded.max = value; + bounded.xmax = false; + break; + case "<": + bounded.max = value; + bounded.xmax = true; + break; + } + if ( + bounded.min !== undefined && + bounded.max !== undefined && + (bounded.min > bounded.max || (bounded.min === bounded.max && (bounded.xmin || bounded.xmax))) + ) { + throw new OmpTypeError(`numeric range is unsatisfiable in "${src}"`); + } + return bounded; + } + if (node.k === "string" || node.k === "array") { + if (!Number.isInteger(value) || value < 0) { + throw new OmpTypeError(`length bound must be a non-negative integer in "${src}"`); + } + const bounded = { ...node }; + switch (op) { + case ">=": + bounded.min = value; + break; + case ">": + bounded.min = value + 1; + break; + case "<=": + bounded.max = value; + break; + case "<": + bounded.max = value - 1; + break; + } + if ( + (bounded.min !== undefined && bounded.max !== undefined && bounded.min > bounded.max) || + (bounded.max ?? 0) < 0 + ) { + throw new OmpTypeError(`length range is unsatisfiable in "${src}"`); + } + return bounded; + } + 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, descAuto: schema.ir.desc === undefined }; + if (schema.description !== undefined && schema.ir.desc === undefined) { + return { ...schema.ir, desc: schema.description, descAuto: true }; + } + return schema.ir; +} +function boundKind(node: IR): "number" | "length" | "date" | undefined { + if (node.k === "number") return "number"; + if (node.k === "string" || node.k === "array") return "length"; + if (acceptsDate(node)) return "date"; + return undefined; +} + +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, + }; + } + if (typeof def === "string") { + const parsed = parseStringDef(def, resolve); + return { + val: parsed.ir, + opt: parsed.optional || parsed.hasDefault, + def: parsed.def, + hasDefault: parsed.hasDefault, + }; + } + return { val: parseDef(def, resolve), opt: false }; +} + +function cloneTuple(tuple: TupleIR): TupleIR { + return { + ...tuple, + prefix: tuple.prefix.map(item => ({ ...item })), + postfix: [...tuple.postfix], + }; +} + +function hasOptionalPrefix(tuple: TupleIR): boolean { + return tuple.prefix.some(item => item.opt || item.hasDefault === true); +} + +function appendTupleItem(tuple: TupleIR, item: TupleItemIR): void { + if (tuple.variadic !== undefined) { + if (item.opt || item.hasDefault) { + throw new OmpTypeError("An optional element may not follow a variadic element"); + } + if (hasOptionalPrefix(tuple)) { + throw new OmpTypeError("A postfix required element cannot follow an optional or defaultable element"); + } + tuple.postfix.push(item.val); + return; + } + if (item.hasDefault && tuple.prefix.some(prefixItem => prefixItem.opt && !prefixItem.hasDefault)) { + throw new OmpTypeError("A defaultable element may not follow an optional element without a default"); + } + if (hasOptionalPrefix(tuple) && !item.opt) { + throw new OmpTypeError("required tuple elements cannot follow optional elements"); + } + tuple.prefix.push(item); +} + +function appendTuple(target: TupleIR, spread: TupleIR): void { + if (target.variadic !== undefined && spread.variadic !== undefined) { + throw new OmpTypeError("a tuple may have one spread followed by an array definition"); + } + for (const item of spread.prefix) appendTupleItem(target, { ...item }); + if (spread.variadic !== undefined) { + target.variadic = spread.variadic; + } + for (const item of spread.postfix) appendTupleItem(target, { val: item, opt: false }); +} + +function spreadAlternatives(spread: IR): TupleIR[] { + if (spread.k === "alias") return spreadAlternatives(spread.resolve()); + if (spread.k === "sub") return spreadAlternatives(spread.schema.ir); + if (spread.k === "union") return spread.members.flatMap(spreadAlternatives); + if (spread.k === "array") return [{ k: "tuple", prefix: [], variadic: spread.el, postfix: [] }]; + if (spread.k === "tuple") return [spread]; + throw new OmpTypeError("tuple spread element must be an array"); +} + +function parseTuple(def: readonly unknown[], resolve?: AliasResolver): IR { + let branches: TupleIR[] = [{ k: "tuple", prefix: [], postfix: [] }]; + for (let index = 0; index < def.length; index++) { + if (def[index] === "...") { + if (index + 1 >= def.length) { + throw new OmpTypeError("a tuple may have one spread followed by an array definition"); + } + const alternatives = spreadAlternatives(parseDef(def[++index], resolve)); + const distributed: TupleIR[] = []; + for (const branch of branches) { + for (const alternative of alternatives) { + const next = cloneTuple(branch); + appendTuple(next, alternative); + distributed.push(next); + } + } + branches = distributed; + continue; + } + const item = parseTupleItem(def[index], resolve); + for (const branch of branches) appendTupleItem(branch, { ...item }); + } + return branches.length === 1 ? branches[0] : { k: "union", members: branches }; +} + +/** Build the runtime schema for an object's or tuple's keys. */ +export function keyOf(node: IR): IR { + if (node.k === "alias") return keyOf(node.resolve()); + if (node.k === "sub") return keyOf(node.schema.ir); + if (node.k === "refine") return keyOf(node.base); + if (node.k === "intersection") { + const members = node.members.flatMap(member => { + const keys = keyOf(member); + return keys.k === "union" ? keys.members : [keys]; + }); + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (node.k === "object") { + const members: IR[] = node.props.map(prop => ({ k: "lit", v: prop.key })); + if (node.index !== undefined || (node.patternIndexes?.length ?? 0) > 0) members.push({ k: "string" }); + if (node.symbolIndex !== undefined) members.push({ k: "symbol" }); + 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 }; + if (node.k === "union") { + if (node.members.length === 0) return { k: "never" }; + const literalSets = node.members.map(member => { + const keyed = keyOf(member); + const literals = keyed.k === "union" ? keyed.members : [keyed]; + const keys = new Set<PropertyKey>(); + for (const literal of literals) { + if ( + literal.k === "lit" && + (typeof literal.v === "string" || typeof literal.v === "number" || typeof literal.v === "symbol") + ) { + keys.add(literal.v); + } + } + return keys; + }); + const common = [...literalSets[0]].filter(key => literalSets.slice(1).every(keys => keys.has(key))); + if (common.length === 0) throw new OmpTypeError("keyof operand must be an object"); + const members = common.map(value => ({ k: "lit", v: value }) satisfies IR); + return members.length === 1 ? members[0] : { k: "union", members }; + } + throw new OmpTypeError("keyof operand must be an object"); +} + +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: ctor === Error ? "an Error" : `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]; + const name = predicate.name; + const expected = name.length === 0 ? "valid according to an anonymous predicate" : `valid according to ${name}`; + return { + k: "refine", + base: parseDef(def[0], resolve), + pred: value => { + let errors: OmpErrors | undefined; + const error = ( + input: + | string + | { + expected: string; + actual?: unknown; + path?: readonly PropertyKey[]; + relativePath?: readonly PropertyKey[]; + }, + ): OmpErrors => { + const detail = typeof input === "string" ? { expected: input } : input; + const next = OmpErrors.single([...(detail.path ?? detail.relativePath ?? [])], detail.expected, value, { + preserveActual: true, + ...(Object.hasOwn(detail, "actual") ? { actual: String(detail.actual) } : {}), + }); + if (errors) errors.append(next); + else errors = next; + return next; + }; + const result = predicate(value, { error, reject: error }); + return errors ?? (result instanceof OmpErrors ? result : result === true); + }, + expected, + }; + } + if (def.length >= 3 && def[1] === "@") { + const base = parseDef(def[0], resolve); + const meta = def[2]; + if (typeof meta === "string") return { ...base, desc: meta, cfg: { ...base.cfg, expected: meta } }; + if (typeof meta === "object" && meta !== null) { + const config = meta as ErrorConfig & { description?: string }; + return { + ...base, + cfg: { + ...(typeof config.description === "string" ? { expected: config.description } : {}), + ...config, + }, + ...(typeof config.description === "string" ? { desc: config.description } : {}), + }; + } + return base; + } + return parseTuple(def, resolve); +} + +function isObjectDefinition(def: unknown): def is Record<PropertyKey, unknown> { + return ( + typeof def === "object" && + def !== null && + !Array.isArray(def) && + !(def instanceof RegExp) && + !(def instanceof Date) + ); +} + +function spreadObjectOf(ir: IR): Extract<IR, { k: "object" }> | undefined { + if (ir.k === "alias") return spreadObjectOf(ir.resolve()); + if (ir.k === "sub") return spreadObjectOf(ir.schema.ir); + if (ir.k === "refine") return spreadObjectOf(ir.base); + if (ir.k === "anyobject") return { k: "object", props: [], extras: "keep" }; + if (ir.k === "object") return ir; + if (ir.k !== "intersection") return undefined; + let result: Extract<IR, { k: "object" }> = { k: "object", props: [], extras: "keep" }; + for (const member of ir.members) { + const object = spreadObjectOf(member); + if (object === undefined) return undefined; + result = mergeObjectIR(result, object) as Extract<IR, { k: "object" }>; + } + return result; +} + +function indexKeyKind( + key: IR, + value: IR, + props: PropIR[], + indexes: { + string?: IR; + symbol?: IR; + patterns: { key: IR; val: IR }[]; + }, +): void { + if (key.k === "alias") return indexKeyKind(key.resolve(), value, props, indexes); + if (key.k === "union") { + for (const member of key.members) indexKeyKind(member, value, props, indexes); + return; + } + if (key.k === "lit" && (typeof key.v === "string" || typeof key.v === "symbol")) { + props.push({ key: key.v, opt: false, val: value }); + return; + } + if (key.k === "string") { + indexes.string = value; + return; + } + if (key.k === "symbol") { + indexes.symbol = value; + return; + } + if (key.k === "refine" && key.base.k === "string") { + indexes.patterns.push({ key, val: value }); + return; + } + throw new OmpTypeError(`indexed key definition must resolve to a string or symbol (was ${expectedOf(key)})`); +} + +function addObjectProp(props: PropIR[], spreadKeys: Set<PropertyKey> | undefined, prop: PropIR): void { + if (spreadKeys === undefined) { + props.push(prop); + return; + } + const previous = props.findIndex(candidate => candidate.key === prop.key); + if (previous < 0) { + props.push(prop); + return; + } + if (!spreadKeys.delete(prop.key)) throw new OmpTypeError(`duplicate object key ${String(prop.key)}`); + props[previous] = prop; +} + +function parseObjectDefinition(def: Record<PropertyKey, unknown>, resolve?: AliasResolver): IR { + const props: PropIR[] = []; + let spreadKeys: Set<PropertyKey> | undefined; + let normalizedKey: PropertyKey | undefined; + let normalizedKeys: PropertyKey[] | undefined; + let indexes: + | { + string?: IR; + symbol?: IR; + patterns: { key: IR; val: IR }[]; + } + | undefined; + let extras: Extras = "keep"; + let simple = true; + for (const originalKey in def) { + if (!Object.hasOwn(def, originalKey)) continue; + const val = def[originalKey]; + if (originalKey === "+") { + if (val === "reject" || val === "delete") { + extras = val; + if (val === "delete") simple = false; + } else if (val === "ignore") extras = "keep"; + else throw new OmpTypeError(`bad "+" value ${String(val)}`); + continue; + } + if (originalKey === "...") { + const parsed = parseDef(val, resolve); + const spread = spreadObjectOf(parsed); + if (spread === undefined) { + throw new OmpTypeError(`object spread must resolve to an object literal (was ${expectedOf(parsed)})`); + } + if (simple && !isSimpleIR(spread)) simple = false; + spreadKeys ??= new Set(); + 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; + spreadKeys.add(prop.key); + } + if (spread.index !== undefined || spread.symbolIndex !== undefined || spread.patternIndexes !== undefined) { + const objectIndexes = indexes ?? { patterns: [] }; + indexes = objectIndexes; + objectIndexes.string ??= spread.index; + objectIndexes.symbol ??= spread.symbolIndex; + if (spread.patternIndexes !== undefined) objectIndexes.patterns.push(...spread.patternIndexes); + } + if (spread.extras !== "keep") extras = spread.extras; + continue; + } + if (typeof originalKey === "string" && originalKey.startsWith("[") && originalKey.endsWith("]")) { + let value: IR; + if (typeof val === "string") { + const parsed = parseStringDef(val, resolve); + if (parsed.hasDefault) throw new OmpTypeError("index signatures cannot specify a default"); + value = parsed.ir; + } else { + if (Array.isArray(val) && val.length === 3 && val[1] === "=") { + throw new OmpTypeError("index signatures cannot specify a default"); + } + value = parseDef(val, resolve); + if (isEmbedded(val) && val.hasDefault) { + throw new OmpTypeError("index signatures cannot specify a default"); + } + } + const keyDefinition = originalKey.slice(1, -1); + const regex = /^\/((?:\\.|[^\\/])*)\/([dgimsuvy]*)$/.exec(keyDefinition); + let key: IR; + if (regex === null) { + key = parseDef(keyDefinition, resolve); + } else { + try { + key = patternIR(new RegExp(regex[1], regex[2])); + } catch { + throw new OmpTypeError(`invalid index signature pattern ${keyDefinition}`); + } + } + const objectIndexes = indexes ?? { patterns: [] }; + indexes = objectIndexes; + indexKeyKind(key, value, props, objectIndexes); + if (simple && (!isSimpleIR(key) || !isSimpleIR(value))) simple = false; + continue; + } + const escapedOptional = typeof originalKey === "string" && originalKey.endsWith("\\?"); + const escapedMeta = + typeof originalKey === "string" && + (originalKey === "\\+" || originalKey === "\\..." || originalKey.startsWith("\\[")); + const rawKey = escapedOptional + ? `${originalKey.slice(0, -2)}?` + : escapedMeta + ? originalKey.slice(1) + : originalKey; + const opt = typeof rawKey === "string" && !escapedOptional && !escapedMeta && rawKey.endsWith("?"); + const key = opt ? rawKey.slice(0, -1) : rawKey; + let prop: PropIR; + if (typeof val === "string") { + const parsed = parseStringDef(val, resolve); + prop = { key, opt: opt || parsed.optional, val: parsed.ir }; + if (parsed.hasDefault) { + prop.def = parsed.def; + prop.hasDefault = true; + } + } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") { + prop = { key, opt: true, val: parseDef(val[0], resolve) }; + } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") { + prop = { + key, + opt, + val: parseDef(val[0], resolve), + def: val[2], + defFactory: typeof val[2] === "function", + hasDefault: true, + }; + } else if (isEmbedded(val)) { + prop = val.hasDefault + ? { + key, + opt, + val: embed(val), + def: val.hasDefaultOutput ? val.defaultOutput : val.defaultValue, + defFactory: typeof val.defaultValue === "function", + hasDefault: true, + defValidated: val.hasDefaultOutput, + } + : { key, opt, val: embed(val) }; + } else { + prop = { + key, + opt, + val: isObjectDefinition(val) ? parseObjectDefinition(val, resolve) : parseDef(val, resolve), + }; + } + if (key !== originalKey) { + if (!spreadKeys?.has(key) && props.some(candidate => candidate.key === key)) { + throw new OmpTypeError(`duplicate object key ${String(key)}`); + } + if (normalizedKey === undefined) normalizedKey = key; + else { + normalizedKeys ??= [normalizedKey]; + normalizedKeys.push(key); + } + } else if (!spreadKeys?.has(key) && (key === normalizedKey || normalizedKeys?.includes(key))) { + throw new OmpTypeError(`duplicate object key ${String(key)}`); + } + if (opt && prop.hasDefault) throw new OmpTypeError(`optional key ${String(key)} cannot specify a default`); + if (simple && (prop.hasDefault || !isSimpleIR(prop.val))) simple = false; + addObjectProp(props, spreadKeys, prop); + } + for (const key of Object.getOwnPropertySymbols(def)) { + if (!Object.prototype.propertyIsEnumerable.call(def, key)) continue; + const val = def[key]; + let prop: PropIR; + if (typeof val === "string") { + const parsed = parseStringDef(val, resolve); + prop = { key, opt: parsed.optional, val: parsed.ir }; + if (parsed.hasDefault) { + prop.def = parsed.def; + prop.hasDefault = true; + } + } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") { + prop = { key, opt: true, val: parseDef(val[0], resolve) }; + } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") { + prop = { + key, + opt: false, + val: parseDef(val[0], resolve), + def: val[2], + defFactory: typeof val[2] === "function", + hasDefault: true, + }; + } else if (isEmbedded(val)) { + prop = val.hasDefault + ? { + key, + opt: false, + val: embed(val), + def: val.hasDefaultOutput ? val.defaultOutput : val.defaultValue, + defFactory: typeof val.defaultValue === "function", + hasDefault: true, + defValidated: val.hasDefaultOutput, + } + : { key, opt: false, val: embed(val) }; + } else { + prop = { + key, + opt: false, + val: isObjectDefinition(val) ? parseObjectDefinition(val, resolve) : parseDef(val, resolve), + }; + } + if (simple && (prop.hasDefault || !isSimpleIR(prop.val))) simple = false; + addObjectProp(props, spreadKeys, prop); + } + const object: IR = { + k: "object", + props, + index: indexes?.string, + symbolIndex: indexes?.symbol, + patternIndexes: indexes === undefined || indexes.patterns.length === 0 ? undefined : indexes.patterns, + extras, + }; + object[kSimple] = simple; + object[kSimpleOwner] = object; + return object; +} + +/** 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.hasDefault) { + throw new OmpTypeError("A default may only be specified for an object property or tuple element"); + } + if (parsed.optional) { + throw new OmpTypeError(`optional "?" marker is only valid on object property values`); + } + return parsed.ir; + } + if (Array.isArray(def)) { + if (def.length === 3 && def[1] === "=") { + throw new OmpTypeError("A default may only be specified for an object property or tuple element"); + } + return parseArrayExpression(def, resolve); + } + if (def instanceof RegExp) return patternIR(def); + if (def instanceof Date) return { k: "lit", v: def }; + if (isEmbedded(def)) return embed(def); + if (typeof def === "function") { + const resolved = Reflect.apply(def, undefined, []); + if (!isEmbedded(resolved)) { + throw new OmpTypeError(`thunk must return a Type (was ${typeof resolved})`); + } + return embed(resolved); + } + if (isObjectDefinition(def)) return parseObjectDefinition(def, resolve); + throw new OmpTypeError(`unsupported definition ${String(def)} (was ${typeof def})`); +} + +/** Whether `ir` needs no construction-time normalization or morph analysis. */ +export function isSimpleIR(ir: IR): boolean { + const cached = ir[kSimpleOwner] === ir ? ir[kSimple] : undefined; + if (cached !== undefined) return cached; + const simple = scanSimpleIR(ir); + ir[kSimple] = simple; + ir[kSimpleOwner] = ir; + return simple; +} + +function scanSimpleIR(ir: IR): boolean { + switch (ir.k) { + case "intersection": + case "morph": + case "sub": + case "alias": + return false; + case "refine": + return scanSimpleIR(ir.base); + case "union": + if (ir.members.length < 2) return false; + if ( + ir.members.length === 2 && + ir.members.every(member => member.k === "lit" && typeof member.v === "boolean") + ) { + return false; + } + for (let index = 0; index < ir.members.length; index++) { + const member = ir.members[index]; + if ( + member.k !== "lit" || + (member.v !== null && (typeof member.v === "object" || typeof member.v === "function")) + ) { + return false; + } + for (let previous = 0; previous < index; previous++) { + const candidate = ir.members[previous]; + if (candidate.k === "lit" && candidate.v === member.v) return false; + } + } + return true; + case "array": + return scanSimpleIR(ir.el); + case "tuple": + for (const item of ir.prefix) { + if (item.hasDefault || !scanSimpleIR(item.val)) return false; + } + if (ir.variadic !== undefined && !scanSimpleIR(ir.variadic)) return false; + for (const item of ir.postfix) if (!scanSimpleIR(item)) return false; + return true; + case "object": + if (ir.extras === "delete") return false; + for (const prop of ir.props) { + if (prop.hasDefault || !scanSimpleIR(prop.val)) return false; + } + if (ir.index !== undefined && !scanSimpleIR(ir.index)) return false; + if (ir.symbolIndex !== undefined && !scanSimpleIR(ir.symbolIndex)) return false; + if (ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (!scanSimpleIR(pattern.key) || !scanSimpleIR(pattern.val)) return false; + } + } + return true; + default: + return true; + } +} + +/** True when validating `ir` can produce an output different from its input. */ +export function hasMorph(ir: IR): boolean { + const cached = ir[kMorphOwner] === ir ? ir[kMorph] : undefined; + if (cached !== undefined) return cached; + const result = scanMorph(ir); + ir[kMorph] = result; + ir[kMorphOwner] = ir; + return result; +} + +function scanMorph(ir: IR, activeAliases?: Set<IR>): boolean { + const cached = ir[kMorphOwner] === ir ? ir[kMorph] : undefined; + if (cached !== undefined) return cached; + + let result = false; + switch (ir.k) { + case "sub": + case "morph": + result = true; + break; + case "alias": { + if (activeAliases?.has(ir)) return false; + const aliases = activeAliases ?? new Set<IR>(); + aliases.add(ir); + result = scanMorph(ir.resolve(), aliases); + aliases.delete(ir); + return result; + } + case "object": + result = ir.extras === "delete"; + for (let index = 0; !result && index < ir.props.length; index++) { + const prop = ir.props[index]; + result = prop.hasDefault === true || scanMorph(prop.val, activeAliases); + } + if (!result && ir.index !== undefined) result = scanMorph(ir.index, activeAliases); + if (!result && ir.symbolIndex !== undefined) result = scanMorph(ir.symbolIndex, activeAliases); + if (!result && ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (scanMorph(pattern.val, activeAliases)) { + result = true; + break; + } + } + } + break; + case "array": + result = scanMorph(ir.el, activeAliases); + break; + case "union": + case "intersection": + for (const member of ir.members) { + if (scanMorph(member, activeAliases)) { + result = true; + break; + } + } + break; + case "refine": + result = scanMorph(ir.base, activeAliases); + break; + case "tuple": + for (const item of ir.prefix) { + if (item.hasDefault === true || scanMorph(item.val, activeAliases)) { + result = true; + break; + } + } + if (!result && ir.variadic !== undefined) result = scanMorph(ir.variadic, activeAliases); + if (!result) { + for (const item of ir.postfix) { + if (scanMorph(item, activeAliases)) { + result = true; + break; + } + } + } + break; + } + return result; +} + +/** + * True when a traversal of `ir` can revisit nodes through recursive aliases, + * requiring cycle guards in the interpreter. Embedded sub-schemas run their + * own guarded traversal and are intentionally not inspected. + */ +export function hasAlias(ir: IR): boolean { + const cached = ir[kAliasOwner] === ir ? ir[kAlias] : undefined; + if (cached !== undefined) return cached; + const result = scanAlias(ir); + ir[kAlias] = result; + ir[kAliasOwner] = ir; + return result; +} + +function scanAlias(ir: IR): boolean { + const cached = ir[kAliasOwner] === ir ? ir[kAlias] : undefined; + if (cached !== undefined) return cached; + switch (ir.k) { + case "alias": + return true; + case "object": + for (const prop of ir.props) if (scanAlias(prop.val)) return true; + if (ir.index !== undefined && scanAlias(ir.index)) return true; + if (ir.symbolIndex !== undefined && scanAlias(ir.symbolIndex)) return true; + if (ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (scanAlias(pattern.key) || scanAlias(pattern.val)) return true; + } + } + return false; + case "array": + return scanAlias(ir.el); + case "tuple": + for (const item of ir.prefix) if (scanAlias(item.val)) return true; + if (ir.variadic !== undefined && scanAlias(ir.variadic)) return true; + for (const item of ir.postfix) if (scanAlias(item)) return true; + return false; + case "union": + case "intersection": + for (const member of ir.members) if (scanAlias(member)) return true; + return false; + case "refine": + return scanAlias(ir.base); + case "morph": + return scanAlias(ir.input) || (ir.out !== undefined && scanAlias(ir.out)); + default: + return false; + } +} + +/** Human-readable expectation for error messages, e.g. `"a string"`. */ +export function expectedOf(ir: IR): string { + if (ir.desc !== undefined) return ir.desc; + 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 === "this" ? expectedOf(ir.resolve()) : ir.name; + case "sub": + return ir.desc ?? ir.schema.description ?? 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..b5f050831 --- /dev/null +++ b/packages/omptype/src/json-schema.ts @@ -0,0 +1,312 @@ +import type { IR, PropIR } from "./ir"; + +export interface JsonSchemaOptions { + description?: string; + target?: string; + dialect?: string | null; + /** + * Which side of morphs and defaults to describe: + * - `'input'` — accepted payloads: morphs emit their input shape, defaulted + * properties are optional (with `default` annotations). + * - `'output'` — produced values: morphs emit their output shape, defaulted + * properties are required (always present after validation). + * Unset keeps the hybrid legacy behavior (morph output, defaults optional). + */ + io?: "input" | "output"; + fallback?: (context: { base: Record<string, unknown> }) => unknown; +} + +type JsonSchema = Record<string, unknown>; + +interface EmitCtx { + options?: JsonSchemaOptions; + /** Emitted alias definitions, attached as `$defs` on the root schema. */ + defs: Map<string, JsonSchema>; + /** Alias resolver identity → assigned `$defs` name (cycle-safe). */ + refs: Map<() => IR, string>; +} + +/** Emit the requested JSON Schema dialect represented by an IR tree. */ +export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema { + const ctx: EmitCtx = { options, defs: new Map(), refs: new Map() }; + let schema = emit(ir, ctx); + if (ctx.defs.size > 0) schema.$defs = Object.fromEntries(ctx.defs); + if (options?.target === "draft-07") schema = toDraft7(schema); + const dialect = options?.dialect === null ? undefined : (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 !== undefined && (target.startsWith("http://") || target.startsWith("https://")) ? target : undefined; +} + +function fallback(schema: JsonSchema, ctx: EmitCtx): JsonSchema { + const replacement = ctx.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 === "$ref" && typeof value === "string") { + converted.$ref = value.replace("#/$defs/", "#/definitions/"); + } else if (key === "$defs") { + converted.definitions = toDraft7(value as JsonSchema); + } else 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, ctx: EmitCtx): JsonSchema { + let schema: JsonSchema; + switch (ir.k) { + case "unknown": + schema = {}; + break; + case "undefined": + schema = fallback({}, ctx); + break; + case "null": + schema = { type: "null" }; + break; + case "boolean": + schema = { type: "boolean" }; + break; + case "bigint": + schema = { type: "integer" }; + break; + case "symbol": + schema = fallback({}, ctx); + 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, ctx); + break; + case "intersection": + schema = { allOf: ir.members.map(member => emit(member, ctx)) }; + break; + case "array": + schema = { type: "array", items: emit(ir.el, ctx) }; + 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, ctx); + 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.items = false; + } else { + schema.items = emit(ir.variadic, ctx); + } + break; + } + case "object": + schema = emitObject(ir.props, ir.index, ir.extras, ctx); + break; + case "instance": + schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, ctx); + break; + case "refine": + schema = emit(ir.base, ctx); + if (ir.json !== undefined) Object.assign(schema, ir.json); + break; + case "morph": + schema = ctx.options?.io === "input" ? emit(ir.input, ctx) : fallback(emit(ir.out ?? ir.input, ctx), ctx); + break; + case "alias": { + const known = ctx.refs.get(ir.resolve); + if (known !== undefined) { + schema = { $ref: `#/$defs/${known}` }; + break; + } + // Register before lowering so cyclic aliases resolve to the same $ref + // instead of recursing forever. + let name = ir.name.replace(/[^\w.-]/g, "_"); + while ([...ctx.refs.values()].includes(name)) name = `${name}_`; + ctx.refs.set(ir.resolve, name); + ctx.defs.set(name, emit(ir.resolve(), ctx)); + schema = { $ref: `#/$defs/${name}` }; + break; + } + case "sub": + if (ctx.options?.io === "output" && ir.schema.opaqueOutput) { + schema = {}; + } else if (ctx.options?.io === "output" && ir.schema.stepOut !== undefined) { + schema = emit(ir.schema.stepOut, ctx); + } else if (ctx.options?.io === "input") { + schema = emit(ir.schema.ir, ctx); + } else { + schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, ctx), ctx) : emit(ir.schema.ir, ctx); + } + if (ir.schema.ir.desc !== undefined) schema.description = ir.schema.ir.desc; + else if (ir.schema.description !== undefined && ir.descAuto !== true) { + schema.description = ir.schema.description; + } + break; + } + if (ir.desc !== undefined && ir.descAuto !== true) schema.description = ir.desc; + return schema; +} + +function emitString(ir: Extract<IR, { k: "string" }>): 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<IR, { k: "number" }>): 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[], ctx: EmitCtx): JsonSchema { + const defined = members.filter(member => member.k !== "undefined"); + if (defined.length === 0) return {}; + if (defined.length === 1) return emit(defined[0], ctx); + if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) { + const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).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, ctx)) }; +} + +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", + ctx: EmitCtx, +): JsonSchema { + const properties: Record<string, unknown> = {}; + const required: string[] = []; + const filled = (prop: PropIR): boolean => !prop.opt && (ctx.options?.io === "output" || !prop.hasDefault); + for (const prop of props) { + if (typeof prop.key === "symbol") throw new TypeError("Cannot convert a symbol to a string"); + const key = String(prop.key); + const propertySchema = emit(prop.val, ctx); + if (prop.hasDefault) { + propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def; + } + properties[key] = propertySchema; + if (filled(prop)) required.push(key); + } + const schema: JsonSchema = { type: "object", properties }; + if (required.length > 0) schema.required = required; + if (index !== undefined) schema.additionalProperties = emit(index, ctx); + else if (extras === "reject") schema.additionalProperties = false; + return schema; +} + +function isJsonValue(value: unknown, seen = new Set<object>()): 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<string, unknown>)[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..2c3bfafe9 --- /dev/null +++ b/packages/omptype/src/keywords.ts @@ -0,0 +1,380 @@ +import type { IR, MorphContext } from "./ir"; + +const NUMERIC = /^(?:(?!^-0\.?0*$)(?:-?(?:(?:0|[1-9]\d*)(?:\.\d+)?)|\.\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; +} + +function finiteNumber(expected = "a number"): IR { + return { + k: "refine", + base: { k: "unknown" }, + pred: value => typeof value === "number" && !Number.isNaN(value), + expected, + }; +} + +function jsonObject(): IR { + let value: IR; + const resolveValue = (): IR => value; + const object: IR = { + k: "object", + props: [], + index: { k: "alias", name: "$jsonValue", resolve: resolveValue }, + extras: "keep", + }; + const array: IR = { + k: "array", + el: { k: "alias", name: "$jsonValue", resolve: resolveValue }, + desc: "an object", + }; + value = { + k: "union", + members: [ + object, + array, + { k: "number" }, + { k: "string" }, + { k: "lit", v: false }, + { k: "null" }, + { k: "lit", v: true }, + ], + }; + return object; +} + +function instance(ctor: new (...args: never[]) => object, name: string): IR { + return { k: "instance", ctor, expected: `${/^[AEIOU]/.test(name) ? "an" : "a"} ${name} instance` }; +} + +const keywordFactories: Record<string, () => IR> = { + string: () => ({ k: "string" }), + Key: () => ({ k: "union", members: [{ k: "string" }, { k: "symbol" }] }), + "unknown.any": () => ({ k: "unknown" }), + Array: () => ({ k: "array", el: { k: "unknown" } }), + Function: () => instance(Function as unknown as new (...args: never[]) => object, "Function"), + RegExp: () => instance(RegExp, "RegExp"), + File: () => instance(File, "File"), + Error: () => instance(Error, "Error"), + Set: () => instance(Set, "Set"), + Map: () => instance(Map, "Map"), + WeakSet: () => instance(WeakSet, "WeakSet"), + WeakMap: () => instance(WeakMap, "WeakMap"), + Promise: () => instance(Promise, "Promise"), + FormData: () => instance(FormData, "FormData"), + "FormData.parse": () => ({ + k: "morph", + input: instance(FormData, "FormData"), + fn: value => { + const out: Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> = {}; + for (const [key, entry] of (value as FormData).entries()) { + const current = out[key]; + out[key] = current === undefined ? entry : Array.isArray(current) ? [...current, entry] : [current, entry]; + } + return out; + }, + out: { k: "object", props: [], index: { k: "unknown" }, extras: "keep" }, + }), + "object.json": jsonObject, + "object.json.stringify": () => ({ + k: "morph", + input: jsonObject(), + fn: value => JSON.stringify(value), + out: { k: "string" }, + }), + "number.epoch": () => ({ + k: "refine", + base: { + k: "refine", + base: { + k: "refine", + base: finiteNumber("a number representing a Unix timestamp"), + pred: value => Number.isInteger(value), + expected: "an integer representing a Unix timestamp", + }, + pred: value => (value as number) >= -8_640_000_000_000_000, + expected: "a Unix timestamp after -8640000000000000", + }, + pred: value => (value as number) <= 8_640_000_000_000_000, + expected: "a Unix timestamp before 8640000000000000", + }), + "number.safe": () => ({ + k: "refine", + base: { + k: "refine", + base: finiteNumber(), + pred: value => (value as number) >= Number.MIN_SAFE_INTEGER, + expected: `at least ${Number.MIN_SAFE_INTEGER}`, + }, + pred: value => (value as number) <= Number.MAX_SAFE_INTEGER, + expected: `at most ${Number.MAX_SAFE_INTEGER}`, + }), + "number.NaN": () => ({ + k: "refine", + base: { k: "unknown" }, + pred: Number.isNaN, + expected: "NaN", + }), + "number.Infinity": () => ({ k: "lit", v: Number.POSITIVE_INFINITY }), + "number.NegativeInfinity": () => ({ k: "lit", v: Number.NEGATIVE_INFINITY }), + "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..b1a7608bf --- /dev/null +++ b/packages/omptype/src/type.ts @@ -0,0 +1,3973 @@ +/** + * 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, compileAllows } from "./compile"; +import { type ErrorConfig, OmpErrors, OmpTypeError, TraversalError } from "./errors"; +import type { InferDef, InferDefIn, InferObjectLiteral, InferObjectLiteralIn, InferString } from "./infer"; +import { walk } from "./interp"; +import { + type AliasResolver, + type Constructor, + type Def, + distributeFilter, + type EmbeddableSchema, + embed, + expectedOf, + hasMorph, + type IR, + IR_BRAND, + isSimpleIR, + keyOf, + markThisOnlyResolver, + type PropIR, + parseDef, + type TupleIR, + useAssignability, +} from "./ir"; +import { irToJsonSchema, type JsonSchemaOptions } from "./json-schema"; +import { keywordIR, patternIR } from "./keywords"; + +// `Extract`/`Exclude` in the string DSL need assignability, which is defined here. +useAssignability(isSubtype); + +export interface NarrowErrorInput { + readonly expected: string; + readonly actual?: unknown; + readonly path?: readonly PropertyKey[]; + readonly relativePath?: readonly PropertyKey[]; +} + +/** Context passed to `.narrow()` / `.pipe()` callbacks. */ +export interface NarrowContext { + readonly path: readonly PropertyKey[]; + error(error: string | NarrowErrorInput): OmpErrors; + mustBe(expectation: string): false; + reject(problem: string | NarrowErrorInput): OmpErrors | 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, name extends string> = t & { readonly [brand]: name }; + +interface SchemaInference<out t, i = t> { + 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<unknown>; + readonly default?: unknown; + readonly meta: Readonly<Record<string, unknown>>; +} + +/** Structural node returned by `.select()`. */ +export interface SelectedNode { + readonly kind: string; + readonly node: IR; + readonly unit?: unknown; +} + +/** + * Standard Schema V1 (https://standardschema.dev) — the cross-library + * validation interface consumed by tools like @t3-oss/env, tRPC, and + * Hono validators. Inlined per the spec's recommendation; no dependency. + */ +export interface StandardSchemaV1<Input = unknown, Output = Input> { + readonly "~standard": StandardSchemaV1.Props<Input, Output>; +} + +export namespace StandardSchemaV1 { + export interface Props<Input = unknown, Output = Input> { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>; + readonly types?: Types<Input, Output> | undefined; + readonly jsonSchema: { + readonly input: (options: StandardJsonSchemaOptions) => Record<string, unknown>; + readonly output: (options: StandardJsonSchemaOptions) => Record<string, unknown>; + }; + } + export type Result<Output> = SuccessResult<Output> | FailureResult; + export interface SuccessResult<Output> { + readonly value: Output; + readonly issues?: undefined; + } + export interface FailureResult { + readonly issues: readonly Issue[]; + } + export interface Issue { + readonly message: string; + readonly path?: readonly PropertyKey[] | undefined; + } + export interface Types<Input = unknown, Output = Input> { + readonly input: Input; + readonly output: Output; + } +} + +export interface StandardJsonSchemaOptions { + readonly target: "draft-2020-12" | "draft-07" | string; + readonly libraryOptions?: { + readonly dialect?: string | null; + readonly fallback?: JsonSchemaOptions["fallback"]; + }; +} + +/** A compiled schema: callable validator plus composition methods. */ +export interface Type<out t = unknown, i = t> { + (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; + /** Canonical ArkType-compatible expression for diagnostics. */ + readonly expression: string; + /** Canonical structural node representation. */ + readonly json: unknown; + /** Full validate+morph pipeline; identical to calling the schema. */ + readonly run: (data: unknown) => unknown; + /** ArkType-compatible inference alias (type-only; undefined at runtime). */ + readonly t: t; + /** Scope that parsed this schema (or the ambient Ark-compatible scope). */ + readonly $: TypeScope | { readonly internal: { readonly name: "ark" } }; + + /** Inference-only output type (no runtime value). */ + readonly infer: t; + /** Standalone validator for the schema's accepted input. */ + readonly in: FluentType<i>; + /** Standalone validator for its known output, or `unknown` after an opaque morph. */ + readonly out: FluentType<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<string, unknown>; + /** Standard Schema V1 interop (synchronous validation). */ + readonly "~standard": StandardSchemaV1.Props<i, t>; +} + +type MergeTypes<left, right> = left extends object + ? right extends object + ? Omit<left, keyof right> & right + : right + : right; + +type SimplifyNary<t> = t extends object ? { [key in keyof t]: t[key] } : t; +type UnionToIntersection<union> = (union extends unknown ? (value: union) => void : never) extends ( + value: infer intersection, +) => void + ? intersection + : never; + +type NaryOrOutput<definitions extends readonly unknown[]> = InferDef<definitions[number]>; +type NaryOrInput<definitions extends readonly unknown[]> = InferDefIn<definitions[number]>; +type NaryAndOutput<definitions extends readonly unknown[]> = definitions extends readonly [] + ? unknown + : SimplifyNary<UnionToIntersection<InferDef<definitions[number]>>>; +type NaryAndInput<definitions extends readonly unknown[]> = definitions extends readonly [] + ? unknown + : SimplifyNary<UnionToIntersection<InferDefIn<definitions[number]>>>; +// biome-ignore lint/complexity/noBannedTypes: generic accumulator default +type ReduceNaryMergeOutput<definitions extends readonly unknown[], result = {}> = definitions extends readonly [ + infer head, + ...infer tail, +] + ? ReduceNaryMergeOutput<tail, SimplifyNary<MergeTypes<result, InferDef<head>>>> + : definitions extends readonly [] + ? result + : // biome-ignore lint/complexity/noBannedTypes: empty object fallback + {}; +// biome-ignore lint/complexity/noBannedTypes: generic accumulator default +type ReduceNaryMergeInput<definitions extends readonly unknown[], result = {}> = definitions extends readonly [ + infer head, + ...infer tail, +] + ? ReduceNaryMergeInput<tail, SimplifyNary<MergeTypes<result, InferDefIn<head>>>> + : definitions extends readonly [] + ? result + : // biome-ignore lint/complexity/noBannedTypes: empty object fallback + {}; +type NaryMergeOutput<definitions extends readonly unknown[]> = definitions extends readonly [] + ? object + : ReduceNaryMergeOutput<definitions>; +type NaryMergeInput<definitions extends readonly unknown[]> = definitions extends readonly [] + ? object + : ReduceNaryMergeInput<definitions>; + +type PipeItemOutput<item> = + item extends SchemaInference<infer output, unknown> + ? output + : item extends (data: never, ...arguments_: never[]) => infer output + ? Exclude<output, OmpErrors> + : InferDef<item>; +type NaryPipeOutput<items extends readonly unknown[]> = items extends readonly [...(readonly unknown[]), infer last] + ? PipeItemOutput<last> + : unknown; +type NaryPipeInput<items extends readonly unknown[]> = items extends readonly [infer first, ...(readonly unknown[])] + ? first extends SchemaInference<unknown, infer input> + ? input + : first extends (data: infer input, ...arguments_: never[]) => unknown + ? input + : InferDefIn<first> + : unknown; + +interface FluentMethods<t, i> { + describe(description: string): FluentType<t, i>; + configure(config: SchemaConfig, selector?: "self" | ConfigureSelector): FluentType<t, i>; + + default(value: i | (() => i)): FluentType<t, i | undefined>; + optional(): readonly [SchemaInference<t, i>, "?"]; + or<r, ri>(def: SchemaInference<r, ri>): FluentType<t | r, i | ri>; + or<const def extends string>(def: def): FluentType<t | InferString<def>, i | InferString<def>>; + or<const def extends Record<string, unknown>>( + def: def, + ): FluentType<t | InferObjectLiteral<def>, i | InferObjectLiteralIn<def>>; + or(def: Def): FluentType<unknown>; + and<r, ri>(def: SchemaInference<r, ri>): FluentType<t & r, i & ri>; + and<const def extends Record<string, unknown>>( + def: def, + ): FluentType<t & InferObjectLiteral<def>, i & InferObjectLiteralIn<def>>; + and(def: Def): FluentType<unknown>; + equals(def: Def): boolean; + ifEquals(def: Def): FluentType<t, i> | undefined; + ifExtends(def: Def): FluentType<t, i> | undefined; + extends(def: Def): boolean; + overlaps(def: Def): boolean; + distribute<r>( + mapper: (branch: FluentType<unknown>) => SchemaInference<r>, + reducer?: (branches: readonly SchemaInference<r>[]) => SchemaInference<unknown>, + ): FluentType<r>; + select(kind: string): readonly SelectedNode[]; + array(): FluentType<t[], i[]>; + atLeastLength(bound: number): FluentType<t, i>; + atMostLength(bound: number): FluentType<t, i>; + moreThanLength(bound: number): FluentType<t, i>; + lessThanLength(bound: number): FluentType<t, i>; + exactlyLength(bound: number): FluentType<t, i>; + atLeast(bound: number): FluentType<t, i>; + atMost(bound: number): FluentType<t, i>; + moreThan(bound: number): FluentType<t, i>; + lessThan(bound: number): FluentType<t, i>; + divisibleBy(divisor: number): FluentType<t, i>; + positive(): FluentType<t, i>; + negative(): FluentType<t, i>; + nonNegative(): FluentType<t, i>; + nonPositive(): FluentType<t, i>; + matching(pattern: RegExp): FluentType<t, i>; + atOrAfter(bound: Date | number): FluentType<t, i>; + atOrBefore(bound: Date | number): FluentType<t, i>; + laterThan(bound: Date | number): FluentType<t, i>; + earlierThan(bound: Date | number): FluentType<t, i>; + readonly pipe: PipeMethod<t, i>; + to<const def>(def: def): FluentType<InferDef<def>, i>; + filter<narrowed extends i>(fn: (data: i, ctx: NarrowContext) => data is narrowed): FluentType<t, narrowed>; + filter(fn: (data: i, ctx: NarrowContext) => boolean | OmpErrors): FluentType<t, i>; + narrow<narrowed extends t>(fn: (data: t, ctx: NarrowContext) => data is narrowed): FluentType<narrowed, i>; + narrow(fn: (data: t, ctx: NarrowContext) => boolean | OmpErrors): FluentType<t, i>; + brand<const name extends string>(name: name): FluentType<Brand<t, name>, i>; + as<castTo>(): FluentType<castTo, i>; + readonly(): FluentType<Readonly<t>, i>; + extract<r, ri>(def: SchemaInference<r, ri>): FluentType<Extract<t, r>, Extract<i, ri>>; + extract<const def extends string>(def: def): FluentType<Extract<t, InferString<def>>, Extract<i, InferString<def>>>; + extract(def: Def): FluentType<unknown>; + exclude<r, ri>(def: SchemaInference<r, ri>): FluentType<Exclude<t, r>, Exclude<i, ri>>; + exclude<const def extends string>(def: def): FluentType<Exclude<t, InferString<def>>, Exclude<i, InferString<def>>>; + exclude(def: Def): FluentType<unknown>; + onUndeclaredKey(behavior: "ignore" | "reject" | "delete"): FluentType<t, i>; + onDeepUndeclaredKey(behavior: "ignore" | "reject" | "delete"): FluentType<t, i>; +} + +interface PipeMethod<t, i> { + <r>(fn: (data: t, ctx: NarrowContext) => r): FluentType<Exclude<r, OmpErrors>, i>; + <r, ri>(schema: SchemaInference<r, ri>): FluentType<r, i>; + (...steps: readonly unknown[]): FluentType<unknown, i>; + readonly try: { + <r>(fn: (data: t, ctx: NarrowContext) => r): FluentType<Exclude<r, OmpErrors>, i>; + (...steps: readonly unknown[]): FluentType<unknown, i>; + }; +} + +type InputObject<i> = i extends object ? i : object; + +interface ObjectMethods<t extends object, i> { + readonly props: readonly TypeProperty[]; + map( + mapper: (property: TypeProperty) => TypeProperty | readonly TypeProperty[], + ): FluentType<Record<PropertyKey, unknown>>; + keyof(): FluentType<Extract<keyof t, PropertyKey>, Extract<keyof InputObject<i>, PropertyKey>>; + get<const path extends readonly PropertyKey[]>(...path: path): FluentType<unknown>; + pick<const keys extends readonly (keyof t)[]>( + ...keys: keys + ): FluentType<Pick<t, keys[number]>, Pick<InputObject<i>, Extract<keys[number], keyof InputObject<i>>>>; + omit<const keys extends readonly (keyof t)[]>( + ...keys: keys + ): FluentType<Omit<t, keys[number]>, Omit<InputObject<i>, Extract<keys[number], keyof InputObject<i>>>>; + partial(): FluentType<Partial<t>, Partial<InputObject<i>>>; + required(): FluentType<Required<t>, Required<InputObject<i>>>; + merge<r, ri>(def: SchemaInference<r, ri>): FluentType<MergeTypes<t, r>, MergeTypes<i, ri>>; + merge<const def extends Record<string, unknown>>( + def: def, + ): FluentType<MergeTypes<t, InferObjectLiteral<def>>, MergeTypes<i, InferObjectLiteralIn<def>>>; + merge(def: Def): FluentType<unknown>; +} + +type ObjectMethodsFor<t, i> = [t] extends [never] + ? unknown + : [t] extends [readonly unknown[]] + ? unknown + : [t] extends [object] + ? ObjectMethods<t & object, i> + : unknown; +/** Callable schema with fluent methods specialized to its output and input. */ +export type FluentType<t = unknown, i = t> = Type<t, i> & FluentMethods<t, i> & ObjectMethodsFor<t, i>; + +type FnDefinition = Def | SchemaInference<unknown, unknown>; + +/** Function returned by `type.fn`: arguments and an optional return are validated at every call. */ +export type TypedFunction< + parameters extends readonly unknown[] = readonly unknown[], + returns = unknown, + declaredReturns = returns, +> = ((...arguments_: parameters) => returns) & { + readonly params: FluentType<parameters>; + readonly returns: FluentType<declaredReturns>; + readonly expression: string; + readonly raw: (...arguments_: parameters) => returns; +}; + +type InferFnDefinition<definition> = + definition extends SchemaInference<infer output, unknown> ? output : InferDef<definition>; + +type InferFnParameters< + definitions extends readonly unknown[], + accumulator extends readonly unknown[] = [], +> = definitions extends readonly [infer head, ...infer tail] + ? head extends ":" + ? accumulator + : InferFnParameters<tail, readonly [...accumulator, InferFnDefinition<head>]> + : accumulator; + +type InferFnReturn<definitions extends readonly unknown[], inferred> = definitions extends readonly [ + ...(readonly unknown[]), + ":", + infer returns, +] + ? InferFnDefinition<returns> + : inferred; + +type DeclaredFnReturn<definitions extends readonly unknown[]> = definitions extends readonly [ + ...(readonly unknown[]), + ":", + infer returns, +] + ? InferFnDefinition<returns> + : unknown; + +type FnFactory<definitions extends readonly FnDefinition[]> = <result>( + implementation: (...arguments_: InferFnParameters<definitions>) => InferFnReturn<definitions, result>, +) => TypedFunction<InferFnParameters<definitions>, InferFnReturn<definitions, result>, DeclaredFnReturn<definitions>>; + +/** Parses function parameter schemas and validates calls and declared returns. */ +export interface FnParser { + <const definitions extends readonly FnDefinition[]>(...definitions: definitions): FnFactory<definitions>; + raw<const definitions extends readonly FnDefinition[]>(...definitions: definitions): FnFactory<definitions>; +} +interface Step { + kind: "pipe" | "narrow" | "filter"; + fn: (data: unknown, ctx: NarrowContext) => unknown; + /** Convert thrown callback exceptions into validation errors. */ + try?: boolean; + /** Output IR when the step validates its output; drives public `.out`. */ + out?: IR; +} +/** 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 TypeMeta { + description?: string; + defaultValue?: unknown; + hasDefault?: boolean; + defaultOutput?: unknown; + hasDefaultOutput?: boolean; + errorConfig?: ErrorConfig; + clone?: false | ((input: unknown) => unknown); +} +function descriptionOf(ir: IR, seen: Set<IR> = new Set()): string { + if (ir.desc !== undefined) return ir.desc; + if (seen.has(ir)) return ir.k === "alias" ? ir.name : expectedOf(ir); + seen.add(ir); + if (ir.k === "alias") return descriptionOf(ir.resolve(), seen); + if (ir.k === "object") { + return `{ ${ir.props.map(prop => `${String(prop.key)}${prop.opt ? "?" : ""}: ${descriptionOf(prop.val, seen)}`).join(", ")} }`; + } + return expectedOf(ir); +} + +export interface ConfigureSelector { + readonly kind?: string; + readonly where?: (node: { readonly domain?: string; readonly kind: string }) => boolean; +} + +function errorConfigOf(config: SchemaConfig): ErrorConfig { + return { + ...(config.description === undefined || config.expected !== undefined ? {} : { expected: config.description }), + ...(config.expected === undefined ? {} : { expected: config.expected }), + ...(config.actual === undefined ? {} : { actual: config.actual }), + ...(config.problem === undefined ? {} : { problem: config.problem }), + ...(config.message === undefined ? {} : { message: config.message }), + }; +} + +function configureNode(ir: IR, config: SchemaConfig): IR { + return { + ...ir, + cfg: { ...ir.cfg, ...errorConfigOf(config) }, + ...(config.description === undefined ? {} : { desc: config.description }), + }; +} + +function configureSelected(ir: IR, config: SchemaConfig, selector: ConfigureSelector): IR { + const domain = + ir.k === "string" || ir.k === "number" || ir.k === "boolean" || ir.k === "bigint" || ir.k === "symbol" + ? ir.k + : ir.k === "object" || ir.k === "array" || ir.k === "tuple" || ir.k === "instance" || ir.k === "anyobject" + ? "object" + : undefined; + const kind = ir.k === "number" && ir.divisor !== undefined ? "divisor" : "domain"; + if ( + (selector.kind === undefined || selector.kind === kind) && + (selector.where === undefined || selector.where({ kind, domain })) + ) { + return configureNode(ir, config); + } + switch (ir.k) { + case "array": + return { ...ir, el: configureSelected(ir.el, config, selector) }; + case "tuple": + return { + ...ir, + prefix: ir.prefix.map(item => ({ ...item, val: configureSelected(item.val, config, selector) })), + ...(ir.variadic === undefined ? {} : { variadic: configureSelected(ir.variadic, config, selector) }), + postfix: ir.postfix.map(item => configureSelected(item, config, selector)), + }; + case "object": + return { + ...ir, + props: ir.props.map(prop => ({ ...prop, val: configureSelected(prop.val, config, selector) })), + ...(ir.index === undefined ? {} : { index: configureSelected(ir.index, config, selector) }), + symbolIndex: ir.symbolIndex === undefined ? undefined : configureSelected(ir.symbolIndex, config, selector), + patternIndexes: ir.patternIndexes?.map(index => ({ + key: configureSelected(index.key, config, selector), + val: configureSelected(index.val, config, selector), + })), + }; + case "union": + case "intersection": + return { ...ir, members: ir.members.map(member => configureSelected(member, config, selector)) }; + case "refine": + return { ...ir, base: configureSelected(ir.base, config, selector) }; + case "morph": + return { + ...ir, + input: configureSelected(ir.input, config, selector), + ...(ir.out === undefined ? {} : { out: configureSelected(ir.out, config, selector) }), + }; + default: + return ir; + } +} + +class Ctx implements NarrowContext { + expectation: string | undefined; + errors: OmpErrors | undefined; + readonly path: readonly PropertyKey[]; + #data: unknown; + + constructor(data: unknown, path: readonly PropertyKey[] = []) { + this.#data = data; + this.path = path.map(key => (typeof key === "symbol" ? String(key) : key)); + } + + error(input: string | NarrowErrorInput): OmpErrors { + const detail = typeof input === "string" ? { expected: input } : input; + const error = OmpErrors.single([...(detail.path ?? detail.relativePath ?? [])], detail.expected, this.#data, { + preserveActual: true, + ...(Object.hasOwn(detail, "actual") ? { actual: String(detail.actual) } : {}), + }); + if (this.errors) this.errors.append(error); + else this.errors = error; + return error; + } + + mustBe(expectation: string): false { + this.expectation = expectation; + return false; + } + + reject(input: string | NarrowErrorInput): OmpErrors | false { + if (typeof input === "string") { + this.expectation = input; + return false; + } + this.error(input); + return false; + } +} + +type Validator = (data: unknown, path?: readonly PropertyKey[]) => unknown; +type Allows = (data: unknown) => data is unknown; + +const kBase = Symbol("omptype.base"); +const kSteps = Symbol("omptype.steps"); + +const EMPTY_STEPS: Step[] = []; +const EMPTY_META: TypeMeta = {}; +const ARK_COMPAT_SCOPE = Object.freeze({ internal: Object.freeze({ name: "ark" as const }) }); + +interface InternalType + extends Type<unknown, unknown>, + FluentMethods<unknown, unknown>, + ObjectMethods<Record<PropertyKey, unknown>, unknown> { + (data: unknown): unknown; + [IR_BRAND]: true; + [kBase]: Validator; + [kSteps]: Step[]; + clone?: false | ((input: unknown) => unknown); + allows: Allows; + ir: IR; + hasSteps: boolean; + hasDefault: boolean; + /** Output IR of the last `.to(target)` step, when any. */ + stepOut?: IR; + /** True when the last pipe step is bare — output shape statically unknown. */ + opaqueOutput?: boolean; + defaultValue?: unknown; + defaultOutput?: unknown; + hasDefaultOutput?: boolean; + description?: string; + errorConfig?: ErrorConfig; + $: typeof ARK_COMPAT_SCOPE; + resolver?: AliasResolver; + 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, + defaultOutput: schema.defaultOutput, + hasDefaultOutput: schema.hasDefaultOutput, + errorConfig: schema.errorConfig, + clone: schema.clone, + }; +} +function inheritScope(source: InternalType, target: InternalType): InternalType { + if (source.resolver === undefined) return target; + target.resolver = source.resolver; + Reflect.set(target, "$", source.$); + return target; +} +function invalidDefault(label: string, errors: OmpErrors): never { + const error = errors[0]; + let heading = label; + for (let index = 0; index < error.path.length; index++) { + const segment = error.path[index]; + if (typeof segment === "number") { + if (label === "Default" && index === 0) heading = "Default value"; + heading += ` at [${segment}]`; + } else if (label === "Default" && index === 0) { + heading += ` ${String(segment)}`; + } else { + heading += `.${String(segment)}`; + } + } + throw new OmpTypeError(`ParseError: ${heading} ${error.problem}`); +} + +function rejectMutableStaticDefault(value: unknown): void { + if (value !== null && typeof value === "object" && !(value instanceof Date)) { + throw new OmpTypeError("ParseError: A mutable default value must be specified as a factory"); + } +} + +function normalizeDefaults(ir: IR, seen = new WeakSet<object>()): void { + if (seen.has(ir)) return; + seen.add(ir); + switch (ir.k) { + case "object": + for (const prop of ir.props) { + normalizeDefaults(prop.val, seen); + if (!prop.hasDefault || prop.defValidated) continue; + let candidate: unknown; + let factory = false; + if (prop.defFactory && typeof prop.def === "function") { + candidate = prop.def(); + factory = true; + } else { + rejectMutableStaticDefault(prop.def); + candidate = prop.def; + } + const output = walk(prop.val, candidate); + if (output instanceof OmpErrors) invalidDefault(`Default for ${String(prop.key)}`, output); + if (!factory) prop.def = output; + prop.defValidated = true; + } + if (ir.index) normalizeDefaults(ir.index, seen); + if (ir.symbolIndex) normalizeDefaults(ir.symbolIndex, seen); + if (ir.patternIndexes) { + for (const index of ir.patternIndexes) { + normalizeDefaults(index.key, seen); + normalizeDefaults(index.val, seen); + } + } + return; + case "tuple": + for (let index = 0; index < ir.prefix.length; index++) { + const item = ir.prefix[index]; + normalizeDefaults(item.val, seen); + if (!item.hasDefault || item.defValidated) continue; + let candidate: unknown; + let factory = false; + if (item.defFactory && typeof item.def === "function") { + candidate = item.def(); + factory = true; + } else { + rejectMutableStaticDefault(item.def); + candidate = item.def; + } + const output = walk(item.val, candidate); + if (output instanceof OmpErrors) invalidDefault(`Default for [${index}]`, output); + if (!factory) item.def = output; + item.defValidated = true; + } + if (ir.variadic) normalizeDefaults(ir.variadic, seen); + for (const item of ir.postfix) normalizeDefaults(item, seen); + return; + case "array": + normalizeDefaults(ir.el, seen); + return; + case "union": + case "intersection": + for (const member of ir.members) normalizeDefaults(member, seen); + return; + case "refine": + normalizeDefaults(ir.base, seen); + return; + case "morph": + normalizeDefaults(ir.input, seen); + if (ir.out) normalizeDefaults(ir.out, seen); + return; + default: + return; + } +} + +/** Emitted for `io: 'output'` when a bare pipe makes the output unknowable. */ +const OPAQUE_OUTPUT_IR: IR = { k: "unknown" }; +const typeMethods = { + describe(this: InternalType, description: string): InternalType { + const ir = { ...this.ir, desc: description, cfg: { ...this.ir.cfg, expected: description } }; + return makeType(ir, this[kSteps], { ...metaOf(this), description }); + }, + + configure(this: InternalType, config: SchemaConfig, selector: "self" | ConfigureSelector = "self"): InternalType { + const selected = + selector === "self" ? configureNode(this.ir, config) : configureSelected(this.ir, config, selector); + return makeType(selected, this[kSteps], { + ...metaOf(this), + errorConfig: { ...this.errorConfig, ...errorConfigOf(config) }, + ...(config.description === undefined ? {} : { description: config.description }), + }); + }, + + default(this: InternalType, value: unknown): InternalType { + const factory = typeof value === "function"; + if (!factory) rejectMutableStaticDefault(value); + const candidate = factory ? value() : value; + const output = this.run(candidate); + if (output instanceof OmpErrors) invalidDefault("Default", output); + return makeType(this.ir, this[kSteps], { + ...metaOf(this), + defaultValue: value, + hasDefault: true, + ...(factory ? {} : { defaultOutput: output, hasDefaultOutput: true }), + }); + }, + + optional(this: InternalType): readonly [InternalType, "?"] { + return [this, "?"]; + }, + + or(this: InternalType, def: Def): InternalType { + const other = parseDef(def, this.resolver); + const a = embed(this); + const members = [...(a.k === "union" ? a.members : [a]), ...(other.k === "union" ? other.members : [other])]; + return inheritScope(this, makeType({ k: "union", members }, [], {})); + }, + + equals(this: InternalType, def: Def): boolean { + return irEquals(embed(this), parseDef(def, this.resolver)); + }, + + ifEquals(this: InternalType, def: Def): InternalType | undefined { + return irEquals(embed(this), parseDef(def, this.resolver)) ? this : undefined; + }, + + ifExtends(this: InternalType, def: Def): InternalType | undefined { + return isSubtype(embed(this), parseDef(def, this.resolver)) ? this : undefined; + }, + + extends(this: InternalType, def: Def): boolean { + return isSubtype(embed(this), parseDef(def, this.resolver)); + }, + + overlaps(this: InternalType, def: Def): boolean { + try { + intersect(embed(this), parseDef(def, this.resolver)); + return true; + } catch (error) { + if (error instanceof OmpTypeError) return false; + throw error; + } + }, + + distribute( + this: InternalType, + mapper: (branch: BaseType) => InternalType, + reducer?: (branches: readonly InternalType[]) => InternalType, + ): InternalType { + const branches = this.ir.k === "union" ? this.ir.members : [embed(this)]; + const mapped = branches.map(branch => mapper(makeType(branch, [], {}) as unknown as BaseType)); + if (reducer !== undefined) return reducer(mapped); + const members = mapped.map(branch => embed(branch)); + 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 inheritScope(this, makeType(intersect(embed(this), parseDef(def, this.resolver)), [], {})); + }, + + array(this: InternalType): InternalType { + return inheritScope(this, 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 | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "at or after", value => value >= timestamp); + }, + + atOrBefore(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "at or before", value => value <= timestamp); + }, + + laterThan(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "later than", value => value > timestamp); + }, + + earlierThan(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "earlier than", value => value < timestamp); + }, + + pipe(this: InternalType, ...pipes: readonly unknown[]): InternalType { + return appendPipes(this, pipes, false); + }, + + to(this: InternalType, def: unknown): InternalType { + return appendPipes(this, [makeType(parseDef(def, this.resolver), [], {})], false, true); + }, + + 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 this; + }, + + as(this: InternalType): InternalType { + return this; + }, + + readonly(this: InternalType): InternalType { + return this; + }, + + keyof(this: InternalType): InternalType { + return makeType(keyOf(this.ir), [], {}); + }, + + get(this: InternalType, ...path: PropertyKey[]): InternalType { + if (path.length === 0) return this; + let result = this.ir; + for (const key of path) result = getPathIR(result, key); + return makeType(result, [], {}); + }, + + pick(this: InternalType, ...keys: PropertyKey[]): InternalType { + return makeType(selectObjectProps(this.ir, keys, true, "pick"), [], {}); + }, + + omit(this: InternalType, ...keys: PropertyKey[]): InternalType { + return makeType(selectObjectProps(this.ir, keys, false, "omit"), [], {}); + }, + + partial(this: InternalType): InternalType { + return makeType(setObjectOptionality(this.ir, true, "partial"), [], {}); + }, + + required(this: InternalType): InternalType { + return makeType(setObjectOptionality(this.ir, false, "required"), [], {}); + }, + + map(this: InternalType, mapper: (property: TypeProperty) => TypeProperty | readonly TypeProperty[]): InternalType { + const object = requireObject(this.ir, "map"); + const props = object.props.flatMap(prop => { + const original = propertyFromIR(prop); + const mapped = mapper(original); + return (Array.isArray(mapped) ? mapped : [mapped]).map(property => + propertyToIR( + (property.kind === "required" || property.kind === "optional" + ? property + : { ...property, kind: original.kind }) as TypeProperty, + ), + ); + }); + return makeType({ ...object, props }, [], {}); + }, + + merge(this: InternalType, def: unknown): InternalType { + const merged = mergeObjectDefinition(this.ir, def, this.resolver); + return inheritScope(this, makeType(merged, [], {})); + }, + + extract(this: InternalType, def: unknown): InternalType { + return inheritScope(this, makeType(distributeFilter(this.ir, parseDef(def, this.resolver), true), [], {})); + }, + + exclude(this: InternalType, def: unknown): InternalType { + return inheritScope(this, makeType(distributeFilter(this.ir, parseDef(def, this.resolver), false), [], {})); + }, + + onUndeclaredKey(this: InternalType, behavior: "ignore" | "reject" | "delete"): InternalType { + const extras = behavior === "ignore" ? "keep" : behavior; + const ir = withShallowExtras(this.ir, extras); + if (extras === "delete" && ir.k === "union") { + const objects = ir.members.filter((member): member is ObjectIR => member.k === "object"); + for (let left = 0; left < objects.length; left++) { + for (let right = left + 1; right < objects.length; right++) { + const sharedRequired = objects[left].props.some( + leftProp => + !leftProp.opt && + objects[right].props.some(rightProp => !rightProp.opt && rightProp.key === leftProp.key), + ); + if (!sharedRequired) { + const leftExpression = expressionOf(objects[left]).replace(/ }$/, ", + (undeclared): delete }"); + const rightExpression = expressionOf(objects[right]).replace(/ }$/, ", + (undeclared): delete }"); + throw new OmpTypeError( + `ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate:\nLeft: ${leftExpression}\nRight: ${rightExpression}`, + ); + } + } + } + } + return makeType(ir, 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): data is unknown { + const steps = this[kSteps]; + let needsPredicates = false; + for (const step of steps) { + if (step.kind !== "pipe") { + needsPredicates = true; + break; + } + } + if (!needsPredicates) { + const allows = compileAllows(this.ir); + // Shadow the shared dispatcher once this schema has its specialized check. + this.allows = allows; + return allows(data); + } + for (const step of steps) { + if (step.kind === "filter" && !step.fn(data, new Ctx(data))) return false; + } + const out = this[kBase](data); + if (out instanceof OmpErrors) return false; + for (const step of steps) { + if (step.kind === "narrow" && !step.fn(out, new Ctx(out))) 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<string, unknown> { + const ir = + options?.io === "output" ? (this.opaqueOutput ? OPAQUE_OUTPUT_IR : (this.stepOut ?? this.ir)) : this.ir; + const description = options?.description ?? this.ir.desc; + if (description === undefined) return irToJsonSchema(ir, options); + return irToJsonSchema(ir, { ...options, description }); + }, +}; + +Object.defineProperty(typeMethods, "expression", { + get(this: InternalType): string { + const input = expressionOf(this.ir); + if (!this.hasSteps) return input; + if (this.opaqueOutput) return `(In: ${input}) => Out<unknown>`; + return `(In: ${input}) => To<${expressionOf(this.stepOut ?? this.ir)}>`; + }, +}); + +Object.defineProperty(typeMethods, "json", { + get(this: InternalType): unknown { + return arkJsonOf(this.ir); + }, +}); + +Object.defineProperty(typeMethods, "props", { + get(this: InternalType): readonly TypeProperty[] { + const object = requireObject(this.ir, "props"); + return object.props.map(prop => propertyFromIR(prop)); + }, +}); + +Object.defineProperty(typeMethods, "~standard", { + get(this: InternalType): StandardSchemaV1.Props { + const jsonSchema = (io: "input" | "output", options: StandardJsonSchemaOptions) => { + if (options.target !== "draft-2020-12" && options.target !== "draft-07") { + throw new OmpTypeError( + `JSONSchema target '${options.target}' is not supported (must be "draft-2020-12" or "draft-07")`, + ); + } + return this.toJsonSchema({ ...options.libraryOptions, target: options.target, io }); + }; + return { + version: 1, + vendor: "omptype", + validate: (value: unknown): StandardSchemaV1.Result<unknown> => { + const out = this.run(value); + return out instanceof OmpErrors + ? { issues: out as unknown as readonly StandardSchemaV1.Issue[] } + : { value: out }; + }, + jsonSchema: { + input: options => jsonSchema("input", options), + output: options => jsonSchema("output", options), + }, + }; + }, +}); + +Object.defineProperty(typeMethods, "in", { + get(this: InternalType): InternalType { + return makeType(projectIO(this.ir, "in"), [], {}); + }, +}); + +Object.defineProperty(typeMethods, "out", { + get(this: InternalType): InternalType { + if (this.opaqueOutput) return makeType({ k: "unknown" }, [], {}); + return makeType(projectIO(this.stepOut ?? this.ir, "out"), [], {}); + }, +}); + +const allowsMethod = typeMethods.allows; +const assertMethod = typeMethods.assert; +const fromMethod = typeMethods.from; +Object.defineProperties(typeMethods, { + description: { + get(this: InternalType): string { + return descriptionOf(this.ir); + }, + }, + allows: { + get(this: InternalType): Allows { + const allows = (data: unknown): data is unknown => allowsMethod.call(this, data); + Object.defineProperty(this, "allows", { value: allows, writable: true }); + return allows; + }, + }, + assert: { + get(this: InternalType): Validator { + const assert = assertMethod.bind(this); + Object.defineProperty(this, "assert", { value: assert }); + return assert; + }, + }, + from: { + get(this: InternalType): Validator { + const from = fromMethod.bind(this); + Object.defineProperty(this, "from", { value: from }); + return from; + }, + }, + pipe: { + get(this: InternalType) { + const pipe = Object.assign((...pipes: readonly unknown[]): InternalType => appendPipes(this, pipes, false), { + try: (...pipes: readonly unknown[]): InternalType => appendPipes(this, pipes, true), + }); + Object.defineProperty(this, "pipe", { value: pipe }); + return pipe; + }, + }, +}); + +// 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<t = unknown, i = t>(ir: IR, steps: Step[], meta: TypeMeta): FluentType<t, i>; +function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { + let morph = false; + if (!isSimpleIR(ir)) { + ir = normalizeIR(ir); + morph = hasMorph(ir); + if (morph) { + normalizeDefaults(ir); + assertDeterminateMorphUnions(ir); + } + } + 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 = (data: unknown): unknown => impl(data); + const errorConfig = meta.errorConfig ?? ir.cfg; + + const filterInput = steps.some(step => step.kind === "filter") ? projectIO(ir, "in") : undefined; + const validate: Validator = + steps.length === 0 + ? base + : (data: unknown, contextPath: readonly PropertyKey[] = []): unknown => { + if (filterInput !== undefined) { + const inputResult = walk(filterInput, data); + if (inputResult instanceof OmpErrors) return inputResult; + } + for (const step of steps) { + if (step.kind !== "filter") continue; + const ctx = new Ctx(data, contextPath); + const result = step.fn(data, ctx); + if (result instanceof OmpErrors) + return errorConfig === undefined ? result : result.configure(errorConfig); + if (ctx.errors) return errorConfig === undefined ? ctx.errors : ctx.errors.configure(errorConfig); + if (!result) { + return OmpErrors.single( + [], + ctx.expectation ?? + (step.fn.name ? `valid according to ${step.fn.name}` : "valid (input predicate failed)"), + data, + 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(out, contextPath); + if (step.kind === "narrow") { + const result = step.fn(out, ctx); + if (result instanceof OmpErrors) { + return errorConfig === undefined ? result : result.configure(errorConfig); + } + if (ctx.errors) return errorConfig === undefined ? ctx.errors : ctx.errors.configure(errorConfig); + if (!result) { + return OmpErrors.single( + [], + ctx.expectation ?? + (step.fn.name ? `valid according to ${step.fn.name}` : "valid (narrow predicate failed)"), + out, + errorConfig, + ); + } + } else { + try { + out = step.fn(out, ctx); + } catch (error) { + if (!step.try) throw error; + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + return OmpErrors.single([], `valid (morph threw ${detail})`, out, errorConfig); + } + if (out instanceof OmpErrors) { + return errorConfig === undefined ? out : out.configure(errorConfig); + } + } + } + return out; + }; + const needsClone = morph || steps.some(step => step.kind === "pipe"); + const clone = meta.clone; + let callable: Validator = validate; + if (needsClone && clone !== undefined) { + if (clone === false) { + callable = (data: unknown, path?: readonly PropertyKey[]): unknown => { + const out = validate(data, path); + if ( + out instanceof OmpErrors || + out === data || + typeof data !== "object" || + data === null || + typeof out !== "object" || + out === null + ) { + return out; + } + if (Array.isArray(data) && Array.isArray(out)) { + data.splice(0, data.length, ...out); + } else { + const target = data as Record<PropertyKey, unknown>; + const source = out as Record<PropertyKey, unknown>; + for (const key of Reflect.ownKeys(target)) { + if (!Object.hasOwn(source, key)) Reflect.deleteProperty(target, key); + } + for (const key of Reflect.ownKeys(source)) target[key] = source[key]; + } + return data; + }; + } else { + callable = (data: unknown, path?: readonly PropertyKey[]): unknown => validate(clone(data), path); + } + } + + // Root defaults materialize for absent input: `schema(undefined)` and the + // Standard Schema boundary (`~standard.validate(undefined)`) both yield the + // default instead of a base-IR rejection. Factories run per call; static + // defaults reuse the precomputed validated output (mutable statics are + // rejected at `.default()` time). + if (meta.hasDefault === true) { + const inner = callable; + const value = meta.defaultValue; + callable = (data: unknown, path?: readonly PropertyKey[]): unknown => { + if (data !== undefined) return inner(data, path); + if (meta.hasDefaultOutput === true) return meta.defaultOutput; + return inner(typeof value === "function" ? (value as () => unknown)() : value, path); + }; + } + + 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.defaultOutput = meta.defaultOutput; + self.hasDefaultOutput = meta.hasDefaultOutput === true; + self.errorConfig = meta.errorConfig ?? ir.cfg; + self.clone = meta.clone; + self.run = callable; + self.$ = ARK_COMPAT_SCOPE; + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.kind !== "pipe") continue; + self.stepOut = step.out; + self.opaqueOutput = step.out === undefined; + break; + } + Object.setPrototypeOf(self, typeMethods); + return self; +} + +function appendPipes( + source: InternalType, + pipes: readonly unknown[], + catchErrors: boolean, + forcePipeline = false, +): InternalType { + let schema = source; + for (const candidate of pipes) { + const isSchema = + (typeof candidate === "function" || (typeof candidate === "object" && candidate !== null)) && + IR_BRAND in candidate; + if (isSchema) { + const target = candidate as InternalType; + if ( + !forcePipeline && + schema[kSteps].length === 0 && + !target.hasSteps && + !hasMorph(schema.ir) && + !hasMorph(target.ir) + ) { + schema = makeType(intersect(schema.ir, target.ir), [], metaOf(schema)); + continue; + } + const out = target.opaqueOutput ? undefined : (target.stepOut ?? target.ir); + schema = makeType( + schema.ir, + [...schema[kSteps], { kind: "pipe", fn: value => target.run(value), out, try: catchErrors }], + metaOf(schema), + ); + continue; + } + if (typeof candidate !== "function") throw new OmpTypeError("pipe operands must be functions or Types"); + schema = makeType( + schema.ir, + [...schema[kSteps], { kind: "pipe", fn: candidate as Step["fn"], try: catchErrors }], + metaOf(schema), + ); + } + return inheritScope(source, schema); +} + +function projectIO(ir: IR, io: "in" | "out"): IR { + switch (ir.k) { + case "morph": + return projectIO(io === "in" || ir.out === undefined ? ir.input : ir.out, io); + case "sub": + if (io === "out" && ir.schema.opaqueOutput) return { k: "unknown" }; + return projectIO(io === "out" ? (ir.schema.stepOut ?? ir.schema.ir) : ir.schema.ir, io); + case "array": + return { ...ir, el: projectIO(ir.el, io) }; + case "tuple": + return { + ...ir, + prefix: ir.prefix.map(item => ({ + ...item, + opt: io === "in" ? item.opt || item.hasDefault === true : item.opt && !item.hasDefault, + val: projectIO(item.val, io), + hasDefault: false, + def: undefined, + defFactory: false, + defValidated: false, + })), + variadic: ir.variadic === undefined ? undefined : projectIO(ir.variadic, io), + postfix: ir.postfix.map(item => projectIO(item, io)), + }; + case "object": + return { + ...ir, + props: ir.props.map(prop => ({ + ...prop, + opt: io === "in" ? prop.opt || prop.hasDefault === true : prop.opt && !prop.hasDefault, + val: projectIO(prop.val, io), + hasDefault: false, + def: undefined, + defFactory: false, + defValidated: false, + })), + index: ir.index === undefined ? undefined : projectIO(ir.index, io), + symbolIndex: ir.symbolIndex === undefined ? undefined : projectIO(ir.symbolIndex, io), + patternIndexes: ir.patternIndexes?.map(index => ({ + key: projectIO(index.key, io), + val: projectIO(index.val, io), + })), + extras: ir.extras === "delete" ? (io === "in" ? "keep" : "reject") : ir.extras, + }; + case "union": + case "intersection": + return { ...ir, members: ir.members.map(member => projectIO(member, io)) }; + case "refine": + return { ...ir, base: projectIO(ir.base, io) }; + case "alias": + return { ...ir, resolve: () => projectIO(ir.resolve(), io) }; + default: + return ir; + } +} + +function morphIdentities(ir: IR, identities: unknown[] = [], seen = new Set<IR>()): unknown[] { + if (seen.has(ir)) return identities; + seen.add(ir); + switch (ir.k) { + case "morph": + identities.push( + ir.out === undefined + ? ir.fn + : `declared:${expressionOf(projectIO(ir.input, "in"))}=>${expressionOf(projectIO(ir.out, "out"))}`, + ); + morphIdentities(ir.input, identities, seen); + if (ir.out !== undefined) morphIdentities(ir.out, identities, seen); + break; + case "sub": { + const schema = ir.schema as InternalType; + for (const step of schema[kSteps]) if (step.kind === "pipe") identities.push(step.fn); + morphIdentities(schema.ir, identities, seen); + break; + } + case "array": + morphIdentities(ir.el, identities, seen); + break; + case "tuple": + for (const item of ir.prefix) morphIdentities(item.val, identities, seen); + if (ir.variadic !== undefined) morphIdentities(ir.variadic, identities, seen); + for (const item of ir.postfix) morphIdentities(item, identities, seen); + break; + case "object": + if (ir.extras === "delete") identities.push(ir); + for (const prop of ir.props) morphIdentities(prop.val, identities, seen); + if (ir.index !== undefined) morphIdentities(ir.index, identities, seen); + if (ir.symbolIndex !== undefined) morphIdentities(ir.symbolIndex, identities, seen); + for (const index of ir.patternIndexes ?? []) morphIdentities(index.val, identities, seen); + break; + case "union": + case "intersection": + for (const member of ir.members) morphIdentities(member, identities, seen); + break; + case "refine": + morphIdentities(ir.base, identities, seen); + break; + case "alias": + morphIdentities(ir.resolve(), identities, seen); + break; + } + return identities; +} + +function assertDeterminateMorphUnions(ir: IR, seen = new Set<IR>()): void { + if (seen.has(ir)) return; + seen.add(ir); + if (ir.k === "union") { + for (let leftIndex = 0; leftIndex < ir.members.length; leftIndex++) { + const left = ir.members[leftIndex]; + const leftMorphs = morphIdentities(left); + for (let rightIndex = leftIndex + 1; rightIndex < ir.members.length; rightIndex++) { + const right = ir.members[rightIndex]; + const rightMorphs = morphIdentities(right); + if (leftMorphs.length === 0 && rightMorphs.length === 0) continue; + if ( + leftMorphs.length === rightMorphs.length && + leftMorphs.every((identity, index) => identity === rightMorphs[index]) + ) { + continue; + } + // Unwrap one alias level eagerly: the disjointness probe relies on + // intersect() throwing, and deferred alias intersections resolve lazily. + let leftInput = projectIO(left, "in"); + let rightInput = projectIO(right, "in"); + if (leftInput.k === "alias") leftInput = leftInput.resolve(); + if (rightInput.k === "alias") rightInput = rightInput.resolve(); + if (leftInput.k === "object" && rightInput.k === "object") { + const leftKeys = new Set(leftInput.props.map(prop => prop.key)); + const rightKeys = new Set(rightInput.props.map(prop => prop.key)); + const leftRejectsRequiredRight = + leftInput.extras === "reject" && + rightInput.props.some(prop => !prop.opt && !prop.hasDefault && !leftKeys.has(prop.key)); + const rightRejectsRequiredLeft = + rightInput.extras === "reject" && + leftInput.props.some(prop => !prop.opt && !prop.hasDefault && !rightKeys.has(prop.key)); + if (leftRejectsRequiredRight || rightRejectsRequiredLeft) continue; + } + try { + intersect(leftInput, rightInput); + } catch (error) { + if (error instanceof OmpTypeError) continue; + throw error; + } + throw new OmpTypeError("an unordered union with overlapping morph inputs is indeterminate"); + } + } + } + switch (ir.k) { + case "alias": + assertDeterminateMorphUnions(ir.resolve(), seen); + break; + case "morph": + assertDeterminateMorphUnions(ir.input, seen); + if (ir.out !== undefined) assertDeterminateMorphUnions(ir.out, seen); + break; + case "sub": + assertDeterminateMorphUnions(ir.schema.ir, seen); + break; + case "array": + assertDeterminateMorphUnions(ir.el, seen); + break; + case "tuple": + for (const item of ir.prefix) assertDeterminateMorphUnions(item.val, seen); + if (ir.variadic !== undefined) assertDeterminateMorphUnions(ir.variadic, seen); + for (const item of ir.postfix) assertDeterminateMorphUnions(item, seen); + break; + case "object": + for (const prop of ir.props) assertDeterminateMorphUnions(prop.val, seen); + if (ir.index !== undefined) assertDeterminateMorphUnions(ir.index, seen); + if (ir.symbolIndex !== undefined) assertDeterminateMorphUnions(ir.symbolIndex, seen); + for (const index of ir.patternIndexes ?? []) assertDeterminateMorphUnions(index.val, seen); + break; + case "union": + case "intersection": + for (const member of ir.members) assertDeterminateMorphUnions(member, seen); + break; + case "refine": + assertDeterminateMorphUnions(ir.base, seen); + break; + } +} + +function getPathIR(ir: IR, requestedKey: unknown): IR { + let key = requestedKey; + if ((typeof key === "function" || (typeof key === "object" && key !== null)) && IR_BRAND in key) { + const keySchema = key as InternalType; + if (keySchema.ir.k === "symbol") key = Symbol.for("omptype.index"); + else { + const arrayIndex: unknown = type.arrayIndex; + if (keySchema === arrayIndex) key = 0; + else { + throw new OmpTypeError( + `${keySchema.expression} is not allowed as an array or object index; use a concrete property key`, + ); + } + } + } + if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") { + throw new OmpTypeError(`get keys must be strings, numbers, or symbols`); + } + if (ir.k === "alias") return getPathIR(ir.resolve(), key); + if (ir.k === "sub") return getPathIR(ir.schema.ir, key); + if (ir.k === "union") { + return unionOf(ir.members.map(member => getPathIR(member, key))); + } + if (ir.k === "array") { + const index = typeof key === "number" ? key : typeof key === "string" && /^\d+$/.test(key) ? Number(key) : -1; + if (!Number.isSafeInteger(index) || index < 0) throw new OmpTypeError(`key ${String(key)} is not declared`); + return unionOf([ir.el, { k: "undefined" }]); + } + if (ir.k === "tuple") { + const index = typeof key === "number" ? key : typeof key === "string" && /^\d+$/.test(key) ? Number(key) : -1; + if (!Number.isSafeInteger(index) || index < 0) throw new OmpTypeError(`key ${String(key)} is not declared`); + if (index < ir.prefix.length) { + const item = ir.prefix[index]; + return item.opt ? unionOf([{ k: "undefined" }, item.val]) : item.val; + } + if (ir.variadic === undefined) throw new OmpTypeError(`key ${String(key)} is not declared`); + return unionOf([{ k: "undefined" }, ir.variadic, ...ir.postfix]); + } + if (ir.k === "undefined") return ir; + if (ir.k !== "object") throw new OmpTypeError("get requires an object schema"); + + const matches: IR[] = []; + const prop = ir.props.find(candidate => candidate.key === String(key)); + if (prop !== undefined) matches.push(prop.val); + if (typeof key === "string") { + if (ir.index !== undefined) matches.push(ir.index); + for (const index of ir.patternIndexes ?? []) { + if (!(walk(index.key, key) instanceof OmpErrors)) matches.push(index.val); + } + } else if (typeof key === "symbol" && ir.symbolIndex !== undefined) { + matches.push(ir.symbolIndex); + } + if (matches.length === 0) throw new OmpTypeError(`key ${String(key)} is not declared`); + const value = matches.reduce((left, right) => intersect(left, right)); + return prop !== undefined && !prop.opt ? value : unionOf([value, { k: "undefined" }]); +} + +function unionOf(members: IR[]): IR { + const flattened = members.flatMap(member => (member.k === "union" ? member.members : [member])); + return flattened.length === 1 ? flattened[0] : { k: "union", members: flattened }; +} + +function expressionOf(ir: IR, ancestors = new Set<IR>()): string { + if (ancestors.has(ir)) return ir.k === "alias" ? ir.name : ir.k; + const nextAncestors = new Set(ancestors); + nextAncestors.add(ir); + const child = (node: IR): string => expressionOf(node, nextAncestors); + switch (ir.k) { + case "alias": + return child(ir.resolve()); + case "sub": + return child(ir.schema.ir); + case "unknown": + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + return ir.k; + case "anyobject": + return "object"; + case "string": + return "string"; + case "number": + return ir.divisor !== undefined ? `number % ${ir.divisor}` : ir.int ? "number % 1" : "number"; + case "lit": + return typeof ir.v === "string" ? JSON.stringify(ir.v) : String(ir.v); + case "array": { + const element = child(ir.el); + return `${ir.el.k === "union" || ir.el.k === "intersection" ? `(${element})` : element}[]`; + } + case "tuple": { + const items = ir.prefix.map(item => { + const value = child(item.val); + if (item.hasDefault) return `${value} = ${child({ k: "lit", v: item.def })}`; + return `${value}${item.opt ? "?" : ""}`; + }); + if (ir.variadic !== undefined) items.push(`...${child(ir.variadic)}[]`); + items.push(...ir.postfix.map(child)); + return `[${items.join(", ")}]`; + } + case "object": { + const properties = ir.props.map(prop => `${String(prop.key)}${prop.opt ? "?" : ""}: ${child(prop.val)}`); + if (ir.index !== undefined) properties.unshift(`[string]: ${child(ir.index)}`); + if (ir.symbolIndex !== undefined) properties.unshift(`[symbol]: ${child(ir.symbolIndex)}`); + return `{ ${properties.join(", ")} }`; + } + case "union": + return [...new Set(ir.members.map(child))].join(" | "); + case "intersection": + return ir.members.map(child).join(" & "); + case "refine": + return child(ir.base); + case "morph": + return `(In: ${child(ir.input)}) => Out<${child(ir.out ?? { k: "unknown" })}>`; + case "instance": + return ir.ctor.name || "object"; + } +} + +function arkJsonOf(ir: IR, ancestors = new Set<IR>()): unknown { + if (ancestors.has(ir)) return ir.k === "alias" ? { alias: ir.name } : { cyclic: ir.k }; + const nextAncestors = new Set(ancestors); + nextAncestors.add(ir); + const child = (node: IR): unknown => arkJsonOf(node, nextAncestors); + switch (ir.k) { + case "alias": + return child(ir.resolve()); + case "sub": + return child(ir.schema.ir); + case "lit": + return { + unit: ir.v === undefined ? "undefined" : typeof ir.v === "bigint" ? `${ir.v}n` : ir.v, + }; + case "null": + return { unit: null }; + case "undefined": + return { unit: "undefined" }; + case "boolean": + return [{ unit: false }, { unit: true }]; + case "union": + case "intersection": + return ir.members.map(child); + case "array": + return { proto: "Array", sequence: child(ir.el) }; + case "tuple": + return { + proto: "Array", + sequence: { + prefix: ir.prefix.map(item => child(item.val)), + ...(ir.variadic === undefined ? {} : { variadic: child(ir.variadic) }), + ...(ir.postfix.length === 0 ? {} : { postfix: ir.postfix.map(child) }), + }, + }; + case "object": + return { + domain: "object", + required: ir.props + .filter(prop => !prop.opt && !prop.hasDefault) + .map(prop => ({ key: prop.key, value: child(prop.val) })), + optional: ir.props + .filter(prop => prop.opt || prop.hasDefault) + .map(prop => ({ + key: prop.key, + value: child(prop.val), + ...(prop.hasDefault + ? { + default: + prop.defFactory && typeof prop.def === "function" + ? `$ark.${prop.def.name || "default"}` + : prop.def, + } + : {}), + })), + }; + case "refine": + return child(ir.base); + case "morph": + return { in: child(ir.input), ...(ir.out === undefined ? {} : { declaredOut: child(ir.out) }) }; + case "instance": + return { proto: ir.ctor.name }; + case "anyobject": + return { domain: "object" }; + default: + return ir.k; + } +} + +type ObjectIR = Extract<IR, { k: "object" }>; + +function requireObject(ir: IR, operation: string): ObjectIR { + if (ir.k !== "object") throw new OmpTypeError(`${operation} requires an object schema`); + return ir; +} +function selectObjectProps(ir: IR, keys: readonly PropertyKey[], keepSelected: boolean, operation: string): ObjectIR { + const object = requireObject(ir, operation); + const selected = new Set(keys); + for (const key of selected) { + if (!object.props.some(prop => prop.key === key)) throw new OmpTypeError(`key ${String(key)} does not exist`); + } + return { ...object, props: object.props.filter(prop => selected.has(prop.key) === keepSelected) }; +} + +function setObjectOptionality(ir: IR, optional: boolean, operation: string): ObjectIR { + const object = requireObject(ir, operation); + return { ...object, props: object.props.map(prop => ({ ...prop, opt: optional })) }; +} + +function mergeObjectDefinition(ir: IR, definition: unknown, resolve?: AliasResolver): ObjectIR { + return mergeObjects(requireObject(ir, "merge"), requireObject(parseDef(definition, resolve), "merge")); +} + +function propertyFromIR(prop: PropIR): TypeProperty { + return { + kind: prop.opt ? "optional" : "required", + key: prop.key, + value: makeType(prop.val, [], {}) as unknown as FluentType<unknown>, + ...(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: property.key, + opt: property.kind === "optional", + val: embed(property.value), + ...(hasDefault + ? { def: property.default, defFactory: typeof property.default === "function", hasDefault: true } + : {}), + }; +} + +function acceptsDateIR(ir: IR): boolean { + if (ir.k === "instance") return ir.ctor === Date; + if (ir.k === "refine") return acceptsDateIR(ir.base); + if (ir.k === "union") return ir.members.every(acceptsDateIR); + return false; +} + +function dateRefinement( + schema: InternalType, + timestamp: number, + relation: string, + predicate: (value: number) => boolean, +): InternalType { + if (!Number.isFinite(timestamp)) throw new OmpTypeError("date bound must be valid"); + if (!acceptsDateIR(schema.ir)) throw new OmpTypeError("date bounds require a Date type"); + const bound = new Date(timestamp); + 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<IR>(); + 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); + if (node.symbolIndex !== undefined) visit(node.symbolIndex); + for (const index of node.patternIndexes ?? []) { + visit(index.key); + visit(index.val); + } + 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, + symbolIndex: right.symbolIndex ?? left.symbolIndex, + patternIndexes: + left.patternIndexes === undefined && right.patternIndexes === undefined + ? undefined + : [...(left.patternIndexes ?? []), ...(right.patternIndexes ?? [])], + extras: right.extras === "keep" ? left.extras : right.extras, + }; +} + +function withShallowExtras(ir: IR, extras: ObjectIR["extras"]): IR { + if (ir.k === "object") return { ...ir, extras }; + if (ir.k === "union") return { ...ir, members: ir.members.map(member => withShallowExtras(member, extras)) }; + if (ir.k === "alias") return withShallowExtras(ir.resolve(), extras); + throw new OmpTypeError("onUndeclaredKey requires an object schema"); +} + +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), + symbolIndex: ir.symbolIndex === undefined ? undefined : withDeepExtras(ir.symbolIndex, extras), + patternIndexes: ir.patternIndexes?.map(index => ({ + key: withDeepExtras(index.key, extras), + val: withDeepExtras(index.val, 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; + } +} + +function intersectTupleWithArray(tuple: TupleIR, array: Extract<IR, { k: "array" }>): IR { + if (array.min !== undefined || array.max !== undefined) { + return { k: "intersection", members: [tuple, array] }; + } + return { + ...tuple, + prefix: tuple.prefix.map(item => ({ ...item, val: intersect(item.val, array.el) })), + variadic: tuple.variadic === undefined ? undefined : intersect(tuple.variadic, array.el), + postfix: tuple.postfix.map(item => intersect(item, array.el)), + }; +} + +function intersectTuples(left: TupleIR, right: TupleIR): IR { + if ( + left.postfix.length !== 0 || + right.postfix.length !== 0 || + left.prefix.some(item => item.hasDefault) || + right.prefix.some(item => item.hasDefault) + ) { + return { k: "intersection", members: [left, right] }; + } + const leftRequired = left.prefix.filter(item => !item.opt).length; + const rightRequired = right.prefix.filter(item => !item.opt).length; + const minimum = Math.max(leftRequired, rightRequired); + const leftMaximum = left.variadic === undefined ? left.prefix.length : Number.POSITIVE_INFINITY; + const rightMaximum = right.variadic === undefined ? right.prefix.length : Number.POSITIVE_INFINITY; + const maximum = Math.min(leftMaximum, rightMaximum); + if (minimum > maximum) throw new OmpTypeError("tuple length intersection is unsatisfiable"); + + const prefixLength = Number.isFinite(maximum) ? maximum : Math.max(left.prefix.length, right.prefix.length); + const prefix: TupleIR["prefix"] = []; + for (let index = 0; index < prefixLength; index++) { + const leftItem = left.prefix[index]; + const rightItem = right.prefix[index]; + const leftNode = leftItem?.val ?? left.variadic; + const rightNode = rightItem?.val ?? right.variadic; + if (leftNode === undefined || rightNode === undefined) break; + const required = (leftItem !== undefined && !leftItem.opt) || (rightItem !== undefined && !rightItem.opt); + try { + prefix.push({ val: intersect(leftNode, rightNode), opt: !required }); + } catch (error) { + if (required || !(error instanceof OmpTypeError)) throw error; + break; + } + } + const variadic = + leftMaximum === Number.POSITIVE_INFINITY && rightMaximum === Number.POSITIVE_INFINITY + ? intersect(left.variadic!, right.variadic!) + : undefined; + return { k: "tuple", prefix, variadic, postfix: [] }; +} + +const kIntersections = Symbol("omptype.intersections"); + +/** Deferred alias-pair intersections cached on the left node; cyclic references resolve to one node. */ +interface AliasIntersections { + [kIntersections]?: WeakMap<IR, IR>; +} + +/** Intersect two IR nodes, rejecting statically disjoint domains. */ +function intersect(a: IR, b: IR): IR { + if (a.k === "alias" || b.k === "alias") { + // Defer through a lazy alias so cyclic references terminate: revisiting + // the same pair while it is being resolved returns the same node instead + // of recursing forever. + const target = a as IR & AliasIntersections; + target[kIntersections] ??= new WeakMap<IR, IR>(); + const cache = target[kIntersections]; + const existing = cache.get(b); + if (existing !== undefined) return existing; + let resolved: IR | undefined; + const reference: IR = { + k: "alias", + name: a.k === "alias" ? a.name : b.k === "alias" ? b.name : "intersection", + resolve: () => + (resolved ??= intersectResolved(a.k === "alias" ? a.resolve() : a, b.k === "alias" ? b.resolve() : b)), + }; + cache.set(b, reference); + return reference; + } + return intersectResolved(a, b); +} + +function intersectResolved(a: IR, b: IR): IR { + 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 === "morph" && b.k === "morph") { + if (a.fn !== b.fn || a.out !== b.out) { + throw new OmpTypeError("intersection of distinct morphs is indeterminate"); + } + return { ...a, input: intersect(a.input, b.input) }; + } + if (a.k === "morph") return { ...a, input: intersect(a.input, b) }; + if (b.k === "morph") return { ...b, input: intersect(a, b.input) }; + if (a.k === "sub" && a.schema.hasSteps) { + if (b.k === "sub" && b.schema.hasSteps) { + if (a.schema === b.schema) return a; + throw new OmpTypeError("intersection of distinct morphs is indeterminate"); + } + const schema = a.schema as InternalType; + return embed(makeType(intersect(schema.ir, b), schema[kSteps], metaOf(schema))); + } + if (b.k === "sub" && b.schema.hasSteps) return intersect(b, 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]; + const required = (!ap.opt && !ap.hasDefault) || (!bp.opt && !bp.hasDefault); + if (ap.hasDefault && bp.hasDefault && !Object.is(ap.def, bp.def)) { + throw new OmpTypeError( + `ParseError: Invalid intersection of default values ${String(ap.def)} & ${String(bp.def)}`, + ); + } + const defaulted = required ? undefined : ap.hasDefault ? ap : bp.hasDefault ? bp : undefined; + props[index] = { + key: ap.key, + opt: ap.opt && bp.opt, + val: intersect(ap.val, bp.val), + ...(defaulted + ? { + def: defaulted.def, + defFactory: defaulted.defFactory, + hasDefault: true, + defValidated: defaulted.defValidated, + } + : {}), + }; + } + } + 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 === "tuple" && b.k === "tuple") return intersectTuples(a, b); + if (a.k === "tuple" && b.k === "array") return intersectTupleWithArray(a, b); + if (a.k === "array" && b.k === "tuple") return intersectTupleWithArray(b, a); + 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; + if ( + (a.k === "object" && (b.k === "array" || b.k === "tuple")) || + (b.k === "object" && (a.k === "array" || a.k === "tuple")) + ) { + return { k: "intersection", members: [a, b] }; + } + 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 }; +} + +/** Reduce parsed unions/intersections to their observable semantic form. */ +function normalizeIR(ir: IR): IR { + switch (ir.k) { + case "intersection": { + const members = ir.members.map(normalizeIR); + if (members.length === 0) return { k: "unknown" }; + return members.slice(1).reduce(intersect, members[0]); + } + case "union": { + const members: IR[] = []; + let changed = false; + for (let index = 0; index < ir.members.length; index++) { + const original = ir.members[index]; + const member = normalizeIR(original); + changed ||= member !== original; + if (member.k === "union") { + members.push(...member.members); + changed = true; + } else if (member.k === "never") { + changed = true; + } else if (member.k === "unknown") { + return { k: "unknown" }; + } else { + members.push(member); + } + } + if ( + members.every( + member => + member.k === "lit" && + (member.v === null || (typeof member.v !== "object" && typeof member.v !== "function")), + ) + ) { + const pruned: IR[] = []; + for (const member of members) { + if (member.k === "lit" && pruned.some(candidate => candidate.k === "lit" && candidate.v === member.v)) { + changed = true; + } else { + pruned.push(member); + } + } + if (pruned.length === 0) return { k: "never" }; + if (pruned.length === 1) return pruned[0]; + if (pruned.length === 2 && pruned.every(member => member.k === "lit" && typeof member.v === "boolean")) { + return { k: "boolean" }; + } + return changed ? { ...ir, members: pruned } : ir; + } + const pruned = members.filter( + (member, index) => + !members.some( + (candidate, candidateIndex) => + candidateIndex !== index && + !hasMorph(member) && + !hasMorph(candidate) && + isSubtype(member, candidate) && + (!isSubtype(candidate, member) || candidateIndex < index), + ), + ); + if (pruned.length === 0) return { k: "never" }; + if (pruned.length === 1) return pruned[0]; + if (pruned.length === 2 && pruned.every(member => member.k === "lit" && typeof member.v === "boolean")) { + return { k: "boolean" }; + } + if ( + !changed && + !pruned.some(member => member.k === "alias") && + pruned.length === ir.members.length && + pruned.every((member, index) => member === ir.members[index]) + ) { + return ir; + } + return { ...ir, members: pruned }; + } + case "array": { + const element = normalizeIR(ir.el); + return element === ir.el && ir.el.k !== "alias" ? ir : { ...ir, el: element }; + } + case "tuple": { + const prefix = ir.prefix.map(item => { + const value = normalizeIR(item.val); + return value === item.val ? item : { ...item, val: value }; + }); + const variadic = ir.variadic === undefined ? undefined : normalizeIR(ir.variadic); + const postfix = ir.postfix.map(normalizeIR); + if ( + !ir.prefix.some(item => item.val.k === "alias") && + ir.variadic?.k !== "alias" && + !ir.postfix.some(item => item.k === "alias") && + prefix.every((item, index) => item === ir.prefix[index]) && + variadic === ir.variadic && + postfix.every((item, index) => item === ir.postfix[index]) + ) { + return ir; + } + return { ...ir, prefix, variadic, postfix }; + } + case "object": { + let props: PropIR[] | undefined; + for (let index = 0; index < ir.props.length; index++) { + const prop = ir.props[index]; + const value = normalizeIR(prop.val); + if (value === prop.val) continue; + props ??= [...ir.props]; + props[index] = { ...prop, val: value }; + } + const index = ir.index === undefined ? undefined : normalizeIR(ir.index); + const symbolIndex = ir.symbolIndex === undefined ? undefined : normalizeIR(ir.symbolIndex); + const patternIndexes = ir.patternIndexes?.map(pattern => { + const key = normalizeIR(pattern.key); + const val = normalizeIR(pattern.val); + return key === pattern.key && val === pattern.val ? pattern : { key, val }; + }); + if ( + props === undefined && + !ir.props.some(prop => prop.val.k === "alias") && + ir.index?.k !== "alias" && + ir.symbolIndex?.k !== "alias" && + !ir.patternIndexes?.some(pattern => pattern.key.k === "alias" || pattern.val.k === "alias") && + index === ir.index && + symbolIndex === ir.symbolIndex && + patternIndexes?.every((pattern, patternIndex) => pattern === ir.patternIndexes?.[patternIndex]) !== false + ) { + return ir; + } + return { ...ir, props: props ?? ir.props, index, symbolIndex, patternIndexes }; + } + case "refine": { + const base = normalizeIR(ir.base); + return base === ir.base && ir.base.k !== "alias" ? ir : { ...ir, base }; + } + case "morph": { + const input = normalizeIR(ir.input); + const out = ir.out === undefined ? undefined : normalizeIR(ir.out); + return input === ir.input && out === ir.out && ir.input.k !== "alias" && ir.out?.k !== "alias" + ? ir + : { ...ir, input, out }; + } + case "alias": + return ir; + default: + return ir; + } +} + +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<IR, { k: "number" }>, target: Extract<IR, { k: "number" }>): 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<IR, { k: "number" }>, target: Extract<IR, { k: "number" }>): 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<IR, { k: "string" | "array" }>, + target: Extract<IR, { k: "string" | "array" }>, +): 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<IR, Set<IR>>()): 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<IR, { k: "tuple" }>): 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 (!Number.isFinite(bound)) throw new OmpTypeError("numeric bound must be finite"); + if (ir.k === "number") { + return side === "min" ? { ...ir, min: bound, xmin: exclusive } : { ...ir, max: bound, xmax: exclusive }; + } + if (ir.k === "union") { + return { ...ir, members: ir.members.map(member => withNumericBound(member, side, bound, exclusive)) }; + } + throw new OmpTypeError(`cannot apply numeric bound to ${ir.k}`); +} +interface GenericParameter { + readonly name: string; + readonly constraintDef?: unknown; +} + +interface GenericMeta { + readonly parameters: readonly GenericParameter[]; + instantiateIR(arguments_: readonly IR[]): IR; +} + +const GENERIC_META = Symbol("omptype.generic"); + +/** Callable runtime generic returned by `type("<t>", def)` and `type.generic(...)`. */ +export type Generic = (...arguments_: readonly unknown[]) => BaseType; + +interface RuntimeGeneric extends Generic { + readonly [GENERIC_META]: GenericMeta; +} + +/** Schema arguments passed to a callback-bodied runtime generic. */ +export interface GenericArguments { + readonly [name: string]: BaseType; +} + +export interface GenericBuilder { + (definition: (arguments_: GenericArguments) => unknown, hkt?: unknown): Generic; + (definition: unknown, hkt?: unknown): Generic; +} + +function validateGenericParameters(parameters: readonly GenericParameter[]): void { + const names = new Set<string>(); + for (const parameter of parameters) { + if (!/^[A-Za-z_$]\w*$/.test(parameter.name)) { + throw new OmpTypeError(`invalid generic parameter "${parameter.name}"`); + } + if (names.has(parameter.name)) throw new OmpTypeError(`duplicate generic parameter "${parameter.name}"`); + names.add(parameter.name); + } + if (parameters.length === 0) throw new OmpTypeError("generic declarations require at least one parameter"); +} + +function parseGenericParameters(source: string): GenericParameter[] { + const trimmed = source.trim(); + const body = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; + const parts: string[] = []; + let start = 0; + let depth = 0; + let quote = ""; + 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 = Math.max(0, depth - 1); + else if (char === "," && depth === 0) { + parts.push(body.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(body.slice(start).trim()); + const parameters = parts.map(part => { + const constrained = part.match(/^([A-Za-z_$]\w*)\s+extends\s+(.+)$/s); + return constrained === null ? { name: part } : { name: constrained[1], constraintDef: constrained[2].trim() }; + }); + validateGenericParameters(parameters); + return parameters; +} + +function parseGenericDeclaration(source: string): { name: string; parameters: GenericParameter[] } | undefined { + const match = source.trim().match(/^([A-Za-z_$]\w*)\s*(<.*>)$/s); + if (match === null) return undefined; + return { name: match[1], parameters: parseGenericParameters(match[2]) }; +} + +function isRuntimeGeneric(value: unknown): value is RuntimeGeneric { + return typeof value === "function" && GENERIC_META in value; +} + +function genericResolver( + parameters: readonly GenericParameter[], + arguments_: readonly IR[], + outer?: AliasResolver, +): AliasResolver { + const byName = new Map<string, IR>(); + for (let index = 0; index < parameters.length; index++) byName.set(parameters[index].name, arguments_[index]); + const resolve = ((name: string) => byName.get(name) ?? outer?.(name)) as AliasResolver; + resolve.hasGeneric = outer?.hasGeneric; + resolve.generic = outer?.generic; + return resolve; +} + +function parseGenericArgument(definition: unknown, outer?: AliasResolver): IR { + if (outer === undefined) { + try { + return parseDef(definition); + } catch (error) { + if (!(error instanceof OmpTypeError) || !error.message.includes('unknown keyword "this"')) throw error; + } + } + let root: IR | undefined; + const self: IR = { + k: "alias", + name: "this", + resolve: () => { + if (root === undefined || root === self) throw new OmpTypeError('"this" cannot be used as a root definition'); + return root; + }, + }; + const resolve = ((name: string) => (name === "this" ? self : outer?.(name))) as AliasResolver; + if (outer !== undefined) { + resolve.hasGeneric = outer.hasGeneric; + resolve.generic = outer.generic; + } else { + // The retry only exists to serve "this"; parses of this-free member + // strings inside the definition may still share the string cache. + markThisOnlyResolver(resolve); + } + root = parseDef(definition, resolve); + if (root === self) throw new OmpTypeError('"this" cannot be used as a root definition'); + return root; +} + +function genericBodyIR( + parameters: readonly GenericParameter[], + definition: unknown, + arguments_: readonly IR[], + outer?: AliasResolver, +): IR { + const resolve = genericResolver(parameters, arguments_, outer); + const body = + typeof definition === "function" && !(IR_BRAND in definition) + ? definition( + Object.fromEntries( + parameters.map((parameter, index) => [ + parameter.name, + makeType(arguments_[index], EMPTY_STEPS, EMPTY_META) as unknown as BaseType, + ]), + ) as unknown as GenericArguments, + ) + : definition; + return parseDef(body, resolve); +} + +function createRuntimeGeneric( + parameters: readonly GenericParameter[], + definition: unknown, + outer?: AliasResolver, + validateBody = true, +): RuntimeGeneric { + const constraintResolve = ((name: string) => outer?.(name)) as AliasResolver; + constraintResolve.hasGeneric = outer?.hasGeneric; + constraintResolve.generic = outer?.generic; + validateGenericParameters(parameters); + const placeholders = parameters.map(parameter => + parameter.constraintDef === undefined + ? ({ k: "unknown" } as IR) + : parseDef(parameter.constraintDef, constraintResolve), + ); + if (validateBody) genericBodyIR(parameters, definition, placeholders, outer); + const meta: GenericMeta = { + parameters, + instantiateIR(arguments_) { + if (arguments_.length !== parameters.length) { + throw new OmpTypeError(`generic expects ${parameters.length} arguments (received ${arguments_.length})`); + } + for (let index = 0; index < parameters.length; index++) { + const parameter = parameters[index]; + if (parameter.constraintDef === undefined) continue; + const constraint = parseDef(parameter.constraintDef, constraintResolve); + if (!isSubtype(arguments_[index], constraint)) { + throw new OmpTypeError(`${parameter.name} must be assignable to its constraint`); + } + } + return genericBodyIR(parameters, definition, arguments_, outer); + }, + }; + const generic = Object.assign( + (...arguments_: readonly unknown[]) => + makeType( + meta.instantiateIR(arguments_.map(argument => parseGenericArgument(argument, outer))), + EMPTY_STEPS, + EMPTY_META, + ), + { [GENERIC_META]: meta }, + ); + Object.defineProperty(generic, GENERIC_META, { value: meta }); + return generic; +} + +export function type<const definition>(parameters: `<${string}>`, definition: definition): Generic; +export function type<const def>(def: def): FluentType<InferDef<def>, InferDefIn<def>>; +export function type<input, output>( + def: SchemaInference<input> | string, + operator: "=>", + morph: (data: input, ctx: NarrowContext) => output, +): FluentType<Exclude<output, OmpErrors>, input>; +export function type<const input, const output>( + def: input, + operator: "|>", + out: output, +): FluentType<InferDef<output>, InferDefIn<input>>; +export function type<const expression extends readonly unknown[]>( + ...definition: expression +): FluentType<InferDef<expression>, InferDefIn<expression>>; +export function type(first?: unknown): FluentType<unknown> | Generic { + // biome-ignore lint/complexity/noArguments: Avoid allocating a rest array for the dominant single-definition call. + const count = arguments.length; + if (count === 2 && typeof first === "string" && first.trimStart().startsWith("<")) { + // biome-ignore lint/complexity/noArguments: The generic path reads its second positional argument without a rest array. + return createRuntimeGeneric(parseGenericParameters(first), arguments[1]); + } + let definition: unknown = first; + if (count !== 1) { + const expression: unknown[] = new Array(count); + for (let index = 0; index < count; index++) { + // biome-ignore lint/complexity/noArguments: Only multi-part expressions pay to materialize an argument array. + expression[index] = arguments[index]; + } + definition = expression; + } + return makeType<unknown>(parseGenericArgument(definition), EMPTY_STEPS, EMPTY_META); +} + +/** String keyword with a parser that morphs validated text to another output. */ +export interface ParsedStringKeyword<parsed> extends FluentType<string> { + readonly parse: FluentType<parsed, string>; +} + +/** Morphing string keyword paired with its non-morphing preformatted validator. */ +export interface PreformattedKeyword extends FluentType<string, string> { + readonly preformatted: FluentType<string>; +} + +/** Base64 keyword with its URL-safe alphabet variant. */ +export interface Base64Keyword extends FluentType<string> { + readonly url: FluentType<string>; +} + +/** Date-string keyword family. */ +export interface DateStringKeyword extends ParsedStringKeyword<Date> { + readonly iso: ParsedStringKeyword<Date>; + readonly epoch: ParsedStringKeyword<Date>; +} + +/** IP address keyword family. */ +export interface IpKeyword extends FluentType<string> { + readonly v4: FluentType<string>; + readonly v6: FluentType<string>; +} + +/** UUID keyword family. */ +export interface UuidKeyword extends FluentType<string> { + readonly v1: FluentType<string>; + readonly v2: FluentType<string>; + readonly v3: FluentType<string>; + readonly v4: FluentType<string>; + readonly v5: FluentType<string>; + readonly v6: FluentType<string>; + readonly v7: FluentType<string>; + readonly v8: FluentType<string>; +} + +/** 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<number, string>; + readonly integer: FluentType<number, string>; + readonly json: FluentType<unknown, string>; + readonly date: FluentType<Date, string>; + readonly url: FluentType<URL, string>; + readonly boolean: FluentType<boolean, string>; + readonly bigint: FluentType<bigint, string>; +} + +/** Full string keyword module attached to `type.string`. */ +export interface StringKeyword extends FluentType<string> { + readonly alpha: FluentType<string>; + readonly alphanumeric: FluentType<string>; + readonly base64: Base64Keyword; + readonly capitalize: PreformattedKeyword; + readonly creditCard: FluentType<string>; + readonly date: DateStringKeyword; + readonly digits: FluentType<string>; + readonly email: FluentType<string>; + readonly hex: FluentType<string>; + readonly integer: ParsedStringKeyword<number>; + readonly ip: IpKeyword; + readonly json: ParsedStringKeyword<unknown>; + readonly lower: PreformattedKeyword; + readonly normalize: NormalizeKeyword; + readonly numeric: ParsedStringKeyword<number>; + readonly regex: FluentType<string>; + readonly semver: FluentType<string>; + readonly trim: PreformattedKeyword; + readonly upper: PreformattedKeyword; + readonly url: ParsedStringKeyword<URL>; + readonly uuid: UuidKeyword; +} + +/** Number keyword module attached to `type.number`. */ +export interface NumberKeyword extends FluentType<number> { + readonly integer: FluentType<number>; +} + +type Constructed<ctor> = ctor extends abstract new (...args: never[]) => infer instance ? instance : never; + +function keywordSchema<output = string, input = output>(name: string): FluentType<output, input> { + const ir = keywordIR(name); + if (ir === undefined) throw new OmpTypeError(`missing built-in keyword ${name}`); + return makeType<output, input>(ir, [], {}); +} + +function parsedKeyword<parsed>(name: string): ParsedStringKeyword<parsed> { + return Object.assign(keywordSchema<string>(name), { + parse: keywordSchema<parsed, string>(`${name}.parse`), + }); +} + +function preformattedKeyword(name: string): PreformattedKeyword { + return Object.assign(keywordSchema<string, string>(name), { + preformatted: keywordSchema(`${name}.preformatted`), + }); +} + +type MatchDefault<input = unknown, output = unknown> = + | "assert" + | "never" + | "reject" + | ((input: input, ...args: readonly unknown[]) => output); + +type MatchCaseOutput<cases> = { + [key in keyof cases]: cases[key] extends (...args: never[]) => infer output ? output : never; +}[keyof cases]; + +/** A finalized matcher. Like a schema, it returns structured errors unless finalized with `"assert"`. */ +export type Matcher<input = unknown, output = unknown> = FluentType<output, input> & + (<const value extends input>(value: value, ...args: readonly unknown[]) => output | OmpErrors); + +/** Fluent first-match parser exposed as `match` and `type.match`. */ +export interface MatchParser<input = unknown, output = never> { + <const cases extends Record<PropertyKey, unknown>>( + cases: cases, + ): MatchParser<input, output | MatchCaseOutput<cases>> | Matcher<input, output | MatchCaseOutput<cases>>; + case<const definition, result>( + definition: definition, + resolver: (value: InferDef<definition>, ...args: readonly unknown[]) => result, + ): MatchParser<input, output | result>; + match<const cases extends Record<PropertyKey, unknown>>( + cases: cases, + ): MatchParser<input, output | MatchCaseOutput<cases>> | Matcher<input, output | MatchCaseOutput<cases>>; + default<const result>(fallback: MatchDefault<input, result>): Matcher<input, output | result>; + at<const key extends PropertyKey>(key: key): MatchParser<input, output>; + at<const key extends PropertyKey, cases extends Record<PropertyKey, unknown>>( + key: key, + cases: cases, + ): MatchParser<input, output | MatchCaseOutput<cases>> | Matcher<input, output | MatchCaseOutput<cases>>; + strings<const cases extends Record<PropertyKey, unknown>>( + cases: cases, + ): MatchParser<input, output | MatchCaseOutput<cases>> | Matcher<input, output | MatchCaseOutput<cases>>; + in<narrowed>(): MatchParser<narrowed, output>; + in<const definition>(definition: definition): MatchParser<InferDef<definition>, output>; +} + +interface MatchBranch { + readonly definition: unknown; + readonly schema: BaseType; + readonly resolve: (input: unknown, ...args: readonly unknown[]) => unknown; +} + +interface MatchState { + readonly parse: (definition: unknown) => BaseType; + readonly branches: readonly MatchBranch[]; + readonly input?: BaseType; + readonly key?: PropertyKey; +} + +function caseResolver(value: unknown): (input: unknown, ...args: readonly unknown[]) => unknown { + if (typeof value !== "function") throw new OmpTypeError("match case values must be functions"); + return (input, ...args) => Reflect.apply(value, undefined, [input, ...args]); +} + +function unionIR(branches: readonly MatchBranch[]): IR { + const members = branches.map(branch => branch.schema.ir); + if (members.length === 0) return { k: "never" }; + if (members.length === 1) return members[0]; + return { k: "union", members }; +} + +function publicMatcher<input, output>( + state: MatchState, + fallback: MatchDefault<input, output>, +): Matcher<input, output> { + const fallbackResolver = typeof fallback === "function" ? caseResolver(fallback) : undefined; + const casesIR = unionIR(state.branches); + let casesSchema: BaseType; + if (state.key === undefined || state.branches.length === 0) { + casesSchema = + state.key === undefined + ? (makeType(casesIR, EMPTY_STEPS, EMPTY_META) as unknown as BaseType) + : state.parse({ [state.key]: "never" }); + } else { + const first = state.branches[0].schema.ir; + if (first.k !== "object") throw new OmpTypeError("match.at cases must define object schemas"); + const propertyKey = String(state.key); + const values: IR[] = []; + for (const branch of state.branches) { + if (branch.schema.ir.k !== "object") throw new OmpTypeError("match.at cases must define object schemas"); + const property = branch.schema.ir.props.find(candidate => candidate.key === propertyKey); + if (property === undefined) throw new OmpTypeError(`match.at case is missing ${propertyKey}`); + values.push(property.val); + } + const value = values.length === 1 ? values[0] : { k: "union" as const, members: values }; + casesSchema = makeType( + { + ...first, + props: first.props.map(property => (property.key === propertyKey ? { ...property, val: value } : property)), + }, + EMPTY_STEPS, + EMPTY_META, + ) as unknown as BaseType; + } + const execute = (input: unknown, args: readonly unknown[]): unknown => { + let matchedInput = input; + if (state.input !== undefined) { + const validated = state.input.run(input); + if (validated instanceof OmpErrors) return validated; + matchedInput = validated; + } + for (const branch of state.branches) { + const matched = branch.schema.run(matchedInput); + if (!(matched instanceof OmpErrors)) return branch.resolve(matched, ...args); + } + if (fallbackResolver !== undefined) return fallbackResolver(matchedInput, ...args); + return casesSchema.run(matchedInput); + }; + const schema = makeType({ k: "unknown" }, [{ kind: "pipe", fn: input => execute(input, []) }], EMPTY_META); + const callable = ((value: unknown, ...args: readonly unknown[]): unknown => { + const result = execute(value, args); + if ((fallback === "assert" || fallback === "never") && result instanceof OmpErrors) { + throw new TraversalError(result); + } + return result; + }) as unknown as InternalType; + Object.assign(callable, schema); + Object.setPrototypeOf(callable, typeMethods); + return callable as unknown as Matcher<input, output>; +} + +function addMatchCases(state: MatchState, cases: Record<PropertyKey, unknown>): MatchParser { + const branches = [...state.branches]; + let fallback: MatchDefault | undefined; + for (const rawDefinition of Reflect.ownKeys(cases)) { + const value = Reflect.get(cases, rawDefinition); + if (rawDefinition === "default") { + if (value !== "assert" && value !== "never" && value !== "reject" && typeof value !== "function") { + throw new OmpTypeError('match default must be "assert", "never", "reject" or a function'); + } + fallback = value as MatchDefault; + continue; + } + const definition = typeof rawDefinition === "symbol" ? rawDefinition : String(rawDefinition); + const caseDefinition = state.key === undefined ? definition : { [state.key]: definition }; + branches.push({ + definition, + schema: state.parse(caseDefinition), + resolve: caseResolver(value), + }); + } + const next = { ...state, branches }; + return fallback === undefined ? createMatchParser(next) : (publicMatcher(next, fallback) as unknown as MatchParser); +} + +function createMatchParser(state: MatchState): MatchParser { + const parser = ((cases: Record<PropertyKey, unknown>) => addMatchCases(state, cases)) as MatchParser; + parser.case = (definition, resolver) => { + const caseDefinition = state.key === undefined ? definition : { [state.key]: definition }; + return createMatchParser({ + ...state, + branches: [ + ...state.branches, + { + definition, + schema: state.parse(caseDefinition), + resolve: caseResolver(resolver), + }, + ], + }); + }; + parser.match = cases => addMatchCases(state, cases); + parser.default = fallback => publicMatcher(state, fallback); + function at<const key extends PropertyKey>(key: key): MatchParser<unknown, never>; + function at<const key extends PropertyKey, cases extends Record<PropertyKey, unknown>>( + key: key, + cases: cases, + ): MatchParser<unknown, MatchCaseOutput<cases>> | Matcher<unknown, MatchCaseOutput<cases>>; + function at(key: PropertyKey, cases?: Record<PropertyKey, unknown>): unknown { + if (state.key !== undefined) throw new OmpTypeError("match.at may only be specified once"); + const next = createMatchParser({ ...state, key }); + return cases === undefined ? next : next.match(cases); + } + parser.at = at; + parser.strings = cases => { + if (state.key === undefined) throw new OmpTypeError("match.strings requires match.at(key)"); + const definitions: Record<PropertyKey, unknown> = {}; + for (const key of Reflect.ownKeys(cases)) { + definitions[key === "default" ? key : JSON.stringify(String(key))] = Reflect.get(cases, key); + } + return addMatchCases(state, definitions); + }; + parser.in = (...args: unknown[]) => { + if (state.input !== undefined) throw new OmpTypeError("match.in may only be specified once"); + return createMatchParser({ + ...state, + ...(args.length === 0 ? {} : { input: state.parse(args[0]) }), + }); + }; + return parser; +} + +/** Build a fluent first-match dispatcher from schema definitions. */ +const matchBuilder: MatchParser = createMatchParser({ + parse: definition => type.raw(definition), + branches: [], +}); + +export { matchBuilder as match }; + +function fnExpression(ir: IR): string { + switch (ir.k) { + case "unknown": + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + case "string": + case "number": + return ir.k; + case "anyobject": + return "object"; + case "lit": + return typeof ir.v === "string" ? JSON.stringify(ir.v) : String(ir.v); + case "union": + return ir.members.map(fnExpression).join(" | "); + case "intersection": + return ir.members.map(fnExpression).join(" & "); + case "array": { + const element = fnExpression(ir.el); + return ir.el.k === "unknown" ? "Array" : `${element.includes(" | ") ? `(${element})` : element}[]`; + } + case "tuple": { + const elements = ir.prefix.map(item => { + const expression = fnExpression(item.val); + if (item.hasDefault) return `${expression} = ${String(item.def)}`; + return item.opt ? `${expression}?` : expression; + }); + if (ir.variadic !== undefined) elements.push(`...${fnExpression({ k: "array", el: ir.variadic })}`); + elements.push(...ir.postfix.map(fnExpression)); + return `[${elements.join(", ")}]`; + } + case "object": + return `{ ${ir.props + .map(property => `${String(property.key)}${property.opt ? "?" : ""}: ${fnExpression(property.val)}`) + .join(", ")} }`; + case "refine": + return fnExpression(ir.base); + case "morph": + return `(In: ${fnExpression(ir.input)}) => To<${fnExpression(ir.out ?? { k: "unknown" })}>`; + case "instance": + return ir.ctor.name || "object"; + case "alias": + return fnExpression(ir.resolve()); + case "sub": { + const schema = ir.schema; + if (!schema.hasSteps) return fnExpression(schema.ir); + return `(In: ${fnExpression(schema.ir)}) => To<${fnExpression(schema.stepOut ?? { k: "unknown" })}>`; + } + } +} + +function normalizeFnParameter(definition: unknown): unknown { + if (typeof definition !== "string") return definition; + const optional = definition.match(/^(.*[^\s])\?$/); + if (optional) return [optional[1], "?"]; + const defaulted = definition.match(/^(.*?)\s*=\s*(.+)$/); + if (defaulted) { + const source = defaulted[2]; + const value = + source === "true" + ? true + : source === "false" + ? false + : source === "null" + ? null + : Number.isNaN(Number(source)) + ? source + : Number(source); + return [defaulted[1], "=", value]; + } + return definition; +} + +function makeFn(resolve?: AliasResolver): FnParser { + function parser<const definitions extends readonly FnDefinition[]>( + ...definitions: definitions + ): FnFactory<definitions>; + function parser(...definitions: readonly unknown[]): unknown { + const marker = definitions.indexOf(":"); + if (marker !== -1 && (marker !== definitions.length - 2 || definitions.lastIndexOf(":") !== marker)) { + throw new OmpTypeError( + '":" must be followed by exactly one return type e.g:\nfn("string", ":", "number")(s => s.length)', + ); + } + const spreadIndexes: number[] = []; + for (let index = 0; index < definitions.length; index++) { + if (definitions[index] === "...") spreadIndexes.push(index); + } + if (spreadIndexes.length > 1) { + const secondSpread = definitions[spreadIndexes[1] + 1]; + if ( + Array.isArray(secondSpread) && + secondSpread.some( + element => typeof element === "string" && (element.endsWith("?") || element.includes("=")), + ) + ) { + throw new OmpTypeError("An optional element may not follow a variadic element"); + } + throw new OmpTypeError("A tuple may have at most one variadic element"); + } + if (spreadIndexes.length === 1 && spreadIndexes[0] + 2 < (marker === -1 ? definitions.length : marker)) { + const preceding = definitions.slice(0, spreadIndexes[0]); + if ( + preceding.some(element => typeof element === "string" && (element.endsWith("?") || /\s=\s/.test(element))) + ) { + throw new OmpTypeError("A postfix required element cannot follow an optional or defaultable element"); + } + } + const parameterDefinitions = (marker === -1 ? definitions : definitions.slice(0, marker)).map( + normalizeFnParameter, + ); + const params = makeType<readonly unknown[], readonly unknown[]>(parseDef(parameterDefinitions, resolve), [], {}); + const returns = + marker === -1 + ? makeType<unknown>({ k: "unknown" }, [], {}) + : makeType<unknown>(parseDef(definitions[marker + 1], resolve), [], {}); + const parameterExpression = fnExpression(params.ir); + const returnsExpression = fnExpression(returns.ir); + return (implementation: (...arguments_: readonly unknown[]) => unknown) => { + if (typeof implementation !== "function") throw new OmpTypeError("type.fn requires a function implementation"); + const raw = (...arguments_: readonly unknown[]): unknown => { + const validatedArguments = params.assert(arguments_); + const result = Reflect.apply(implementation, undefined, validatedArguments); + return returns.assert(result); + }; + const typed = raw.bind(undefined) as TypedFunction<readonly unknown[], unknown>; + Object.defineProperties(typed, { + name: { value: `bound typed ${implementation.name}`, configurable: true }, + raw: { value: implementation, enumerable: true }, + params: { value: params, enumerable: true }, + returns: { value: returns, enumerable: true }, + expression: { + value: `(${parameterExpression.slice(1, -1)}) => ${returnsExpression}`, + enumerable: true, + }, + }); + return typed; + }; + } + return Object.assign(parser, { raw: parser }); +} +/** Declares a schema output type while preserving its inferred input. */ +export interface DeclaredParser<declared> { + type<const definition>(definition: definition): FluentType<declared, InferDefIn<definition>>; +} + +/** Fix a schema's externally declared static type without changing its runtime validation. */ +// biome-ignore lint/complexity/noBannedTypes: empty default options object +export function declare<declared, _options = {}>(): DeclaredParser<declared> { + return { + type: definition => type(definition) as unknown as FluentType<declared, InferDefIn<typeof definition>>, + }; +} + +function isTypeValue(value: unknown): value is InternalType { + return (typeof value === "function" || (typeof value === "object" && value !== null)) && IR_BRAND in value; +} + +function resolveAlias(ir: IR): IR { + const seen = new Set<IR>(); + let current = ir; + while (current.k === "alias" && !seen.has(current)) { + seen.add(current); + current = current.resolve(); + } + return current; +} + +function sameUnionMember(left: IR, right: IR): boolean { + const a = resolveAlias(left); + const b = resolveAlias(right); + if (a.k === "lit" && b.k === "lit") return Object.is(a.v, b.v); + if (a.k !== b.k) return false; + switch (a.k) { + case "unknown": + case "never": + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "anyobject": + return true; + default: + return false; + } +} + +function buildOr(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildOr<output, input>(definitions: readonly unknown[], resolve?: AliasResolver): FluentType<output, input>; +function buildOr(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + const members: IR[] = []; + const add = (candidate: IR): boolean => { + const resolved = resolveAlias(candidate); + if (resolved.k === "unknown") { + members.length = 0; + members.push(resolved); + return false; + } + if (resolved.k === "never") return true; + if (resolved.k === "union") { + for (const member of resolved.members) { + if (!add(member)) return false; + } + return true; + } + if (!members.some(member => sameUnionMember(member, candidate))) members.push(candidate); + return true; + }; + for (const definition of definitions) { + if (!add(parseDef(definition, resolve))) break; + } + const ir: IR = members.length === 0 ? { k: "never" } : members.length === 1 ? members[0] : { k: "union", members }; + return makeType(ir, [], {}); +} + +function buildAnd(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildAnd<output, input>(definitions: readonly unknown[], resolve?: AliasResolver): FluentType<output, input>; +function buildAnd(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + if (definitions.length === 0) return makeType({ k: "unknown" }, [], {}); + let ir = parseDef(definitions[0], resolve); + for (let index = 1; index < definitions.length; index++) { + ir = intersect(ir, parseDef(definitions[index], resolve)); + } + return makeType(ir, [], {}); +} + +function requireNaryObject(ir: IR): ObjectIR { + const resolved = resolveAlias(ir); + if (resolved.k !== "object") throw new OmpTypeError("merge requires an object schema"); + return resolved; +} + +function buildMerge(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildMerge<output, input>(definitions: readonly unknown[], resolve?: AliasResolver): FluentType<output, input>; +function buildMerge(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + if (definitions.length === 0) return makeType({ k: "anyobject" }, [], {}); + let object = requireNaryObject(parseDef(definitions[0], resolve)); + for (let index = 1; index < definitions.length; index++) { + object = mergeObjects(object, requireNaryObject(parseDef(definitions[index], resolve))); + } + return makeType(object, [], {}); +} + +function buildPipe(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildPipe<output, input>(definitions: readonly unknown[], resolve?: AliasResolver): FluentType<output, input>; +function buildPipe(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + if (definitions.length === 0) return makeType({ k: "unknown" }, [], {}); + const first = definitions[0]; + let schema = + typeof first === "function" && !isTypeValue(first) + ? appendPipes(makeType({ k: "unknown" }, [], {}), [first], false, true) + : isTypeValue(first) + ? first + : makeType(parseDef(first, resolve), [], {}); + for (let index = 1; index < definitions.length; index++) { + const definition = definitions[index]; + const pipe = + typeof definition === "function" && !isTypeValue(definition) + ? definition + : isTypeValue(definition) + ? definition + : makeType(parseDef(definition, resolve), [], {}); + schema = appendPipes(schema, [pipe], false, true); + } + return schema; +} + +function naryStatics(resolve?: AliasResolver) { + return { + or: (...definitions: readonly unknown[]) => buildOr(definitions, resolve), + and: (...definitions: readonly unknown[]) => buildAnd(definitions, resolve), + merge: (...definitions: readonly unknown[]) => buildMerge(definitions, resolve), + pipe: (...definitions: readonly unknown[]) => buildPipe(definitions, resolve), + }; +} + +export namespace type { + /** Error aggregate returned by failed validations (`result instanceof type.errors`). */ + export const errors = OmpErrors; + export type errors = OmpErrors; + + /** Build a union from zero or more definitions. */ + export function or<const definitions extends readonly unknown[]>( + ...definitions: definitions + ): FluentType<NaryOrOutput<definitions>, NaryOrInput<definitions>> { + return buildOr<NaryOrOutput<definitions>, NaryOrInput<definitions>>(definitions); + } + + /** Build an intersection from zero or more definitions. */ + export function and<const definitions extends readonly unknown[]>( + ...definitions: definitions + ): FluentType<NaryAndOutput<definitions>, NaryAndInput<definitions>> { + return buildAnd<NaryAndOutput<definitions>, NaryAndInput<definitions>>(definitions); + } + + /** Right-biased object merge over zero or more definitions. */ + export function merge<const definitions extends readonly unknown[]>( + ...definitions: definitions + ): FluentType<NaryMergeOutput<definitions>, NaryMergeInput<definitions>> { + return buildMerge<NaryMergeOutput<definitions>, NaryMergeInput<definitions>>(definitions); + } + + /** Compose Types, definitions, and morph callbacks from left to right. */ + export function pipe<const definitions extends readonly unknown[]>( + ...definitions: definitions + ): FluentType<NaryPipeOutput<definitions>, NaryPipeInput<definitions>> { + return buildPipe<NaryPipeOutput<definitions>, NaryPipeInput<definitions>>(definitions); + } + + const normalize: NormalizeKeyword = Object.assign(keywordSchema<string, string>("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<Date>("string.date"), { + iso: parsedKeyword<Date>("string.date.iso"), + epoch: parsedKeyword<Date>("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.defineProperties( + makeType<string>({ k: "string" }, [], {}), + Object.getOwnPropertyDescriptors({ + 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<number>("string.integer"), + ip, + json: parsedKeyword<unknown>("string.json"), + lower: preformattedKeyword("string.lower"), + normalize, + numeric: parsedKeyword<number>("string.numeric"), + regex: keywordSchema("string.regex"), + semver: keywordSchema("string.semver"), + trim: preformattedKeyword("string.trim"), + upper: preformattedKeyword("string.upper"), + url: parsedKeyword<URL>("string.url"), + uuid, + }), + ) as unknown as StringKeyword; + + /** Runtime parser keyword family. */ + export const parse: ParseKeyword = { + number: keywordSchema<number, string>("parse.number"), + integer: keywordSchema<number, string>("parse.integer"), + json: keywordSchema<unknown, string>("parse.json"), + date: keywordSchema<Date, string>("parse.date"), + url: keywordSchema<URL, string>("parse.url"), + boolean: keywordSchema<boolean, string>("parse.boolean"), + bigint: keywordSchema<bigint, string>("parse.bigint"), + }; + + /** Number validator with integer refinement. */ + export const number: NumberKeyword = Object.assign(makeType<number>({ k: "number" }, [], {}), { + integer: makeType<number>({ k: "number", int: true }, [], {}), + }); + + /** Schema-valued key representing any non-negative integer array index. */ + export const arrayIndex = makeType<string>( + { + k: "refine", + base: { k: "string" }, + pred: value => typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value), + expected: "a non-negative integer string", + }, + [], + {}, + ); + + /** Boolean validator. */ + export const boolean = makeType<boolean>({ k: "boolean" }, [], {}); + /** Bigint validator. */ + export const bigint = makeType<bigint>({ k: "bigint" }, [], {}); + /** Symbol validator. */ + export const symbol = makeType<symbol>({ k: "symbol" }, [], {}); + /** Non-null object validator. */ + export const object = makeType<object>({ k: "anyobject" }, [], {}); + /** Unknown validator. */ + export const unknown = makeType<unknown>({ k: "unknown" }, [], {}); + /** Alias of the unknown validator. */ + export const any = unknown; + /** Validator that rejects every value. */ + export const never = makeType<never>({ k: "never" }, [], {}); + + /** ArkType's built-in keyword namespace, including invokable utility generics. */ + export const keywords = { + number: { integer: number.integer }, + Map: keywordSchema<Map<unknown, unknown>>("Map"), + Set: keywordSchema<Set<unknown>>("Set"), + RegExp: keywordSchema<RegExp>("RegExp"), + File: keywordSchema<File>("File"), + Error: keywordSchema<Error>("Error"), + // biome-ignore lint/complexity/noBannedTypes: built-in Function keyword + Function: keywordSchema<Function>("Function"), + Array: { + liftFrom<const definition>( + definition: definition, + ): FluentType<InferDef<definition>[], InferDefIn<definition> | InferDefIn<definition>[]> { + const element = parseDef(definition); + const array: IR = { k: "array", el: element, desc: "an object" }; + return makeType<InferDef<definition>[], InferDefIn<definition> | InferDefIn<definition>[]>( + { + k: "morph", + input: { k: "union", members: [element, array] }, + fn: value => (globalThis.Array.isArray(value) ? value : [value]), + out: array, + }, + [], + {}, + ); + }, + }, + Record<const key, const value>( + key: key, + value: value, + ): FluentType< + Record<Extract<InferDef<key>, PropertyKey>, InferDef<value>>, + Record<Extract<InferDefIn<key>, PropertyKey>, InferDefIn<value>> + > { + const keyIR = parseDef(key); + if (keyIR.k !== "string" && keyIR.k !== "symbol") { + throw new OmpTypeError("Record key must be assignable to string or symbol"); + } + const valueIR = parseDef(value); + const ir: IR = + keyIR.k === "symbol" + ? { k: "object", props: [], symbolIndex: valueIR, extras: "keep" } + : { k: "object", props: [], index: valueIR, extras: "keep" }; + return makeType< + Record<Extract<InferDef<key>, PropertyKey>, InferDef<value>>, + Record<Extract<InferDefIn<key>, PropertyKey>, InferDefIn<value>> + >(ir, [], {}); + }, + Partial<const definition>( + definition: definition, + ): FluentType<Partial<InferDef<definition>>, Partial<InputObject<InferDefIn<definition>>>> { + return makeType<Partial<InferDef<definition>>, Partial<InputObject<InferDefIn<definition>>>>( + setObjectOptionality(parseDef(definition), true, "partial"), + [], + {}, + ); + }, + Required<const definition>( + definition: definition, + ): FluentType<Required<InferDef<definition>>, Required<InputObject<InferDefIn<definition>>>> { + return makeType<Required<InferDef<definition>>, Required<InputObject<InferDefIn<definition>>>>( + setObjectOptionality(parseDef(definition), false, "required"), + [], + {}, + ); + }, + Pick<const definition, const keys extends readonly PropertyKey[]>( + definition: definition, + ...keys: keys + ): FluentType< + Pick<InferDef<definition>, Extract<keys[number], keyof InferDef<definition>>>, + Pick<InputObject<InferDefIn<definition>>, Extract<keys[number], keyof InputObject<InferDefIn<definition>>>> + > { + return makeType< + Pick<InferDef<definition>, Extract<keys[number], keyof InferDef<definition>>>, + Pick<InputObject<InferDefIn<definition>>, Extract<keys[number], keyof InputObject<InferDefIn<definition>>>> + >(selectObjectProps(parseDef(definition), keys, true, "pick"), [], {}); + }, + Omit<const definition, const keys extends readonly PropertyKey[]>( + definition: definition, + ...keys: keys + ): FluentType< + Omit<InferDef<definition>, Extract<keys[number], keyof InferDef<definition>>>, + Omit<InputObject<InferDefIn<definition>>, Extract<keys[number], keyof InputObject<InferDefIn<definition>>>> + > { + return makeType< + Omit<InferDef<definition>, Extract<keys[number], keyof InferDef<definition>>>, + Omit<InputObject<InferDefIn<definition>>, Extract<keys[number], keyof InputObject<InferDefIn<definition>>>> + >(selectObjectProps(parseDef(definition), keys, false, "omit"), [], {}); + }, + Merge<const left, const right>( + left: left, + right: right, + ): FluentType<MergeTypes<InferDef<left>, InferDef<right>>, MergeTypes<InferDefIn<left>, InferDefIn<right>>> { + return makeType<MergeTypes<InferDef<left>, InferDef<right>>, MergeTypes<InferDefIn<left>, InferDefIn<right>>>( + mergeObjectDefinition(parseDef(left), right), + [], + {}, + ); + }, + object: { + json: Object.defineProperties(keywordSchema<unknown>("object.json"), { + stringify: { + value: keywordSchema<string, unknown>("object.json.stringify"), + enumerable: true, + }, + }), + }, + unknown: { any: keywordSchema<unknown>("unknown.any") }, + }; + /** Date instance validator. */ + // biome-ignore lint/suspicious/noShadowRestrictedNames: ArkType exposes this exact keyword. + export const Date = makeType<globalThis.Date>({ k: "instance", ctor: globalThis.Date, expected: "a Date" }, [], {}); + + /** Validate instances of `ctor`. */ + export function instanceOf<const ctor extends Constructor>(ctor: ctor): FluentType<Constructed<ctor>> { + if (typeof ctor !== "function" || ctor.prototype === undefined) { + throw new OmpTypeError("instanceof operands must be constructors"); + } + const name = Reflect.get(ctor, "name"); + const expected = + ctor.prototype === Error.prototype + ? "an Error" + : typeof name === "string" && name.length > 0 + ? `an instance of ${name}` + : "an instance"; + return makeType<Constructed<ctor>>({ k: "instance", ctor, expected }, [], {}); + } + + /** Validate one exact unit value. */ + export function unit<const value>(value: value): FluentType<value> { + return makeType<value>({ k: "lit", v: value }, [], {}); + } + + /** Union of literal values from a runtime array. */ + export function enumerated<const values extends readonly unknown[]>(...values: values): FluentType<values[number]> { + 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<values[number]>(ir, [], {}); + } + + /** Enumerate an enum-like object's forward values, excluding numeric reverse mappings. */ + // biome-ignore lint/suspicious/noShadowRestrictedNames: Object.prototype.valueOf method name API + export function valueOf<const values extends Record<PropertyKey, unknown>>( + values: values, + ): FluentType<values[keyof values]> { + const members: IR[] = []; + for (const key in values) { + if (/^(?:0|[1-9]\d*)$/.test(key)) continue; + members.push({ k: "lit", v: values[key] }); + } + const ir: IR = + members.length === 0 ? { k: "never" } : members.length === 1 ? members[0] : { k: "union", members }; + return makeType<values[keyof values]>(ir, [], {}); + } + + /** Fluent first-match dispatcher, also exported as standalone `match`. */ + export const match: MatchParser = matchBuilder; + /** Preserve a definition's literal type while authoring reusable modules. */ + export function define<const definition>(definition: definition): definition { + return definition; + } + + /** Build a function whose arguments and optional declared return are validated. */ + export const fn: FnParser = makeFn(); + + /** Fix an externally declared static type while retaining runtime validation. */ + // biome-ignore lint/complexity/noBannedTypes: empty default options object + export const declare = <declared, _options = {}>(): DeclaredParser<declared> => + ({ + type: definition => type(definition) as unknown as FluentType<declared, InferDefIn<typeof definition>>, + }) as DeclaredParser<declared>; + + /** Build a lazy named scope from aliases and recursive definitions. */ + export function scope(aliases: Record<string, unknown>, options?: ScopeOptions): TypeScope { + return buildScope(aliases, options); + } + + /** Compile a named schema module whose definitions may reference each other. */ + export function module<const definitions extends Record<string, unknown>>( + definitions: definitions, + options?: ScopeOptions, + ): { [name in keyof definitions]: Type<InferDef<definitions[name]>, InferDefIn<definitions[name]>> } { + return scope(definitions, options).export() as unknown as { + [name in keyof definitions]: Type<InferDef<definitions[name]>, InferDefIn<definitions[name]>>; + }; + } + + type GenericParameterSpec = string | readonly [name: string, constraint: unknown]; + + /** Build a generic directly from an angle-bracket declaration. */ + export function generic<const definition>(parameters: `<${string}>`, definition: definition): Generic; + /** Build a curried generic from named, optionally constrained parameters. */ + export function generic(...parameters: readonly GenericParameterSpec[]): GenericBuilder; + export function generic(...arguments_: readonly (GenericParameterSpec | unknown)[]): Generic | GenericBuilder { + if (arguments_.length === 2 && typeof arguments_[0] === "string" && arguments_[0].trimStart().startsWith("<")) { + return createRuntimeGeneric(parseGenericParameters(arguments_[0]), arguments_[1]); + } + const parameters: GenericParameter[] = arguments_.map(parameter => { + if (typeof parameter === "string") return { name: parameter.trim() }; + if (Array.isArray(parameter) && typeof parameter[0] === "string") { + return { name: parameter[0].trim(), constraintDef: parameter[1] }; + } + throw new OmpTypeError("generic parameters must be names or [name, constraint] pairs"); + }); + validateGenericParameters(parameters); + return (definition: unknown) => createRuntimeGeneric(parameters, definition); + } + + /** 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<null>({ k: "null" }, [], {}), + undefined: makeType<undefined>({ k: "undefined" }, [], {}), + true: makeType<true>({ k: "lit", v: true }, [], {}), + false: makeType<false>({ k: "lit", v: false }, [], {}), +}); + +export interface ScopeOptions { + jitless?: boolean; + clone?: false | ((input: unknown) => unknown); + divisor?: SchemaConfig; +} + +/** Callable builder bound to one alias scope. */ +export type ScopedBuilder = (<const definition>( + definition: definition, +) => FluentType<InferDef<definition>, InferDefIn<definition>>) & + typeof type; + +const MODULE_SCOPE = Symbol("omptype.moduleScope"); + +interface RuntimeModule extends Record<string, BaseType | RuntimeModule> { + readonly [MODULE_SCOPE]: TypeScope; +} + +/** Named schema scope with scoped parsing, imports, and bound module exports. */ +export interface TypeScope { + readonly type: ScopedBuilder; + readonly match: MatchParser; + readonly json: Record<string, unknown>; + define<const definition>(definition: definition): definition; + resolve(name: string): BaseType; + import(...names: readonly string[]): Record<string, unknown>; + export(...names: readonly string[]): Record<string, BaseType>; +} + +/** Build a scope whose aliases resolve lazily, including recursive cycles. */ +export function scope(aliases: Record<string, unknown>, options?: ScopeOptions): TypeScope { + return buildScope(aliases, options); +} + +export namespace scope { + /** Preserve a scope definition's literal shape without constructing it. */ + export function define<const aliases>(definitions: aliases): aliases { + return definitions; + } +} + +interface ScopeAlias { + readonly name: string; + readonly sourceName: string; + readonly private: boolean; + readonly genericParameters?: readonly GenericParameter[]; + definition: unknown; + generic?: RuntimeGeneric; + materialized: boolean; +} + +function isRuntimeModule(value: unknown): value is RuntimeModule { + return typeof value === "object" && value !== null && MODULE_SCOPE in value; +} + +function buildScope(aliases: Record<string, unknown>, options?: ScopeOptions): TypeScope { + const scopeMeta: TypeMeta = options?.clone === undefined ? EMPTY_META : { clone: options.clone }; + const withScopeConfig = (ir: IR): IR => + options?.divisor === undefined ? ir : configureSelected(ir, options.divisor, { kind: "divisor" }); + const entries = new Map<string, ScopeAlias>(); + for (const sourceName in aliases) { + const isPrivate = sourceName.startsWith("#"); + const visibleName = isPrivate ? sourceName.slice(1) : sourceName; + const declaration = parseGenericDeclaration(visibleName); + const external = isRuntimeGeneric(aliases[sourceName]) ? aliases[sourceName] : undefined; + const name = declaration?.name ?? visibleName; + if (entries.has(name)) throw new OmpTypeError(`alias "${name}" is declared as both public and private`); + entries.set(name, { + name, + sourceName, + private: isPrivate, + genericParameters: declaration?.parameters ?? external?.[GENERIC_META].parameters, + definition: aliases[sourceName], + generic: external, + materialized: false, + }); + } + + const references = new Map<string, IR>(); + const targets = new Map<string, IR>(); + let scopeValue: TypeScope; + + const materialize = (entry: ScopeAlias): unknown => { + if (entry.materialized) return entry.definition; + entry.materialized = true; + if ( + entry.genericParameters === undefined && + typeof entry.definition === "function" && + !(IR_BRAND in entry.definition) + ) { + entry.definition = Reflect.apply(entry.definition, undefined, []); + } + return entry.definition; + }; + + const moduleSchema = (module: RuntimeModule, parts: readonly string[]): EmbeddableSchema | undefined => { + let current: BaseType | RuntimeModule = module; + for (const part of parts) { + if (!isRuntimeModule(current)) return undefined; + const next: BaseType | RuntimeModule | undefined = current[part]; + if (next === undefined) return undefined; + current = next; + } + if (isRuntimeModule(current)) { + const root = current.root; + return root !== undefined && !isRuntimeModule(root) ? root : undefined; + } + return current; + }; + + const resolve = ((path: string): IR | undefined => { + const [name, ...parts] = path.split("."); + const entry = entries.get(name); + if (entry === undefined || entry.genericParameters !== undefined) return undefined; + const definition = materialize(entry); + if (isRuntimeModule(definition)) { + const schema = moduleSchema(definition, parts); + return schema === undefined ? undefined : embed(schema); + } + if (parts.length !== 0) 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(definition, resolve); + targets.set(name, parsed); + return parsed; + }, + }; + references.set(name, reference); + return reference; + }) as AliasResolver; + + const genericFor = (entry: ScopeAlias): RuntimeGeneric => { + if (entry.generic !== undefined) return entry.generic; + const parameters = entry.genericParameters; + if (parameters === undefined) throw new OmpTypeError(`alias "${entry.name}" is not generic`); + entry.generic = createRuntimeGeneric(parameters, entry.definition, resolve, false); + return entry.generic; + }; + const genericInstantiations = new Map<string, IR>(); + resolve.hasGeneric = name => entries.get(name)?.genericParameters !== undefined; + resolve.generic = (name, arguments_) => { + const entry = entries.get(name); + if (entry === undefined || entry.genericParameters === undefined) return undefined; + const key = `${name}<${arguments_.map(expectedOf).join(",")}>`; + const existing = genericInstantiations.get(key); + if (existing !== undefined) return existing; + let target: IR | undefined; + const reference: IR = { + k: "alias", + name: key, + resolve: () => { + target ??= genericFor(entry)[GENERIC_META].instantiateIR(arguments_); + return target; + }, + }; + genericInstantiations.set(key, reference); + target = genericFor(entry)[GENERIC_META].instantiateIR(arguments_); + return target; + }; + + const bind = (schema: InternalType): InternalType => { + Reflect.set(schema, "$", scopeValue); + Reflect.set(schema, "resolver", resolve); + return schema; + }; + const parseScoped = (definition: unknown): InternalType => + bind(makeType(withScopeConfig(parseDef(definition, resolve)), EMPTY_STEPS, scopeMeta)); + const scopedMatch = createMatchParser({ + parse: definition => parseScoped(definition) as unknown as BaseType, + branches: [], + }); + const scoped = Object.assign((definition: unknown) => parseScoped(definition), type, { + fn: makeFn(resolve), + match: scopedMatch, + ...naryStatics(resolve), + }) as unknown as ScopedBuilder; + + const targetFor = (name: string): IR => { + const resolved = resolve(name); + if (resolved === undefined) throw new OmpTypeError(`unknown alias "${name}"`); + return resolved.k === "alias" ? resolved.resolve() : resolved; + }; + const schemaFor = (name: string): BaseType => + bind(makeType(withScopeConfig(targetFor(name)), EMPTY_STEPS, scopeMeta)) as unknown as BaseType; + + const bindModule = (names: readonly string[]): RuntimeModule => { + const module = {} as RuntimeModule; + Object.defineProperty(module, MODULE_SCOPE, { value: scopeValue }); + for (const name of names) { + const entry = entries.get(name); + if (entry === undefined) continue; + if (entry.genericParameters !== undefined) { + module[name] = genericFor(entry) as unknown as BaseType; + continue; + } + const definition = materialize(entry); + module[name] = isRuntimeModule(definition) ? definition : schemaFor(name); + } + return module; + }; + + scopeValue = { + type: scoped, + match: scopedMatch, + define<const definition>(definition: definition): definition { + return definition; + }, + resolve(name: string) { + return schemaFor(name); + }, + import(...names: readonly string[]) { + const selected = + names.length === 0 + ? [...entries.values()].filter(entry => !entry.private) + : names.map(name => { + const entry = entries.get(name); + if (entry === undefined) throw new OmpTypeError(`unknown alias "${name}"`); + return entry; + }); + const imported: Record<string, unknown> = {}; + for (const entry of selected) { + imported[`#${entry.sourceName.startsWith("#") ? entry.sourceName.slice(1) : entry.sourceName}`] = + entry.genericParameters === undefined ? schemaFor(entry.name) : genericFor(entry); + } + return imported; + }, + export(...names: readonly string[]) { + const selected = + names.length === 0 ? [...entries.values()].filter(entry => !entry.private).map(entry => entry.name) : names; + // Export is the eager boundary: malformed aliases and bad thunks fail here, + // while recursive references inside valid definitions stay lazy. + for (const entry of entries.values()) { + if (entry.genericParameters !== undefined) genericFor(entry); + else if (!isRuntimeModule(materialize(entry))) targetFor(entry.name); + } + return bindModule(selected) as unknown as Record<string, BaseType>; + }, + get json() { + const json: Record<string, unknown> = {}; + const add = (prefix: string, module: RuntimeModule): void => { + for (const name of Object.keys(module)) { + const value = module[name]; + const path = prefix === "" ? name : `${prefix}.${name}`; + if (isRuntimeModule(value)) add(path, value); + else json[path] = Reflect.get(value, "json"); + } + }; + add("", bindModule([...entries.values()].filter(entry => !entry.private).map(entry => entry.name))); + return json; + }, + }; + return scopeValue; +} + +/** A schema whose output type is not statically known (`type.raw` results). */ +export type BaseType = FluentType<unknown, unknown>; + +/** + * Minimal structural constraint matching any omptype schema. + * + * `FluentType`'s recursive fluent surface makes `T extends FluentType<...>` + * checks descend until TypeScript's depth limiter reports spurious + * incompatibilities, and its invariant input parameter rejects concrete + * schemas outright. This interface exposes only the schema marker plus the + * members generic helpers commonly need — method syntax keeps parameter + * positions bivariant, and returns recurse shallowly through `AnyType`. + */ +export interface AnyType { + (data: unknown): unknown; + readonly [IR_BRAND]: true; + readonly ir: IR; + readonly infer: unknown; + readonly inferIn: unknown; + readonly hasDefault: boolean; + readonly description?: string; + run(data: unknown): unknown; + assert(data: unknown): unknown; + allows(data: unknown): boolean; + toJsonSchema(options?: ToJsonSchemaOptions): Record<string, unknown>; + describe(description: string): AnyType; + default(value: unknown): AnyType; + or(def: Def): AnyType; + and(def: Def): AnyType; + pipe(fn: (data: never, ctx: NarrowContext) => unknown): AnyType; + narrow(fn: (data: never, ctx: NarrowContext) => unknown): AnyType; + array(): AnyType; +} +declare const submoduleType: unique symbol; + +type BoundAlias<value> = value extends Submodule<infer aliases> ? Submodule<aliases> : FluentType<value>; + +/** Exported aliases from a scope, each bound to that scope's resolver. */ +export type Module<aliases extends Record<string, unknown>> = { + readonly [name in keyof aliases]: BoundAlias<aliases[name]>; +}; + +/** A module nested under an alias rather than directly parseable as a schema. */ +export type Submodule<aliases extends Record<string, unknown>> = { + readonly [submoduleType]?: aliases; +} & { + readonly [name in keyof aliases]: BoundAlias<aliases[name]>; +}; + +/** A selected module export whose schemas retain access to the full scope. */ +export type BoundModule< + exports extends Record<string, unknown>, + _allAliases extends Record<string, unknown> = exports, +> = Module<exports>; + +/** Type-level view of a named scope. */ +export type Scope<aliases extends Record<string, unknown>> = TypeScope & { + readonly t: aliases; +}; + +/** `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..c3243050a --- /dev/null +++ b/packages/omptype/src/typebox.ts @@ -0,0 +1,526 @@ +import { OmpTypeError } from "./errors"; +import type { Def, EmbeddableSchema } from "./ir"; +import { type NarrowContext, type Type as OmpType, type ToJsonSchemaOptions, 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 | AnySchema; +} +const OPTIONAL_INNER = Symbol("omptype.typebox.optionalInner"); +const OBJECT_INFO = Symbol("omptype.typebox.objectInfo"); + +export interface TypeBoxValidationFailure { + message: string; +} + +export type TypeBoxSafeParseResult<T> = + | { success: true; data: T } + | { success: false; error: TypeBoxValidationFailure }; + +interface LegacyTypeBoxCompat<T> { + /** 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<T>; +} + +/** + * Erased schema surface accepted anywhere this facade takes a schema, native + * omptype schemas included (those carry no legacy compat members). + * + * Members use method syntax so parameter positions stay bivariant: the typed + * {@link TTyped} form is invariant in its static type (its `in`/`out` + * validators are), so a concrete `TString` is not assignable to + * `TTyped<unknown>`. Erasing here is what keeps `TString`, `TObject<…>` and + * friends assignable to a plain schema annotation. + */ +export interface AnySchema extends EmbeddableSchema { + (data: unknown): unknown; + readonly infer: unknown; + readonly hasSteps: boolean; + toJsonSchema(options?: ToJsonSchemaOptions): Record<string, unknown>; +} + +/** + * Every schema this facade returns: the TypeBox `TSchema` analog. Erased like + * {@link AnySchema}, plus the legacy compat members builder results carry. + */ +export interface TSchema extends AnySchema { + /** TypeBox compatibility validator used by legacy extension loaders. */ + __validator(data: unknown): unknown; + /** Zod-style compatibility parser used by legacy extensions. */ + safeParse(input: unknown): TypeBoxSafeParseResult<unknown>; +} + +/** Schema carrying a statically known type; every `TXxx` alias resolves here. */ +export type TTyped<T> = OmpType<T> & LegacyTypeBoxCompat<T>; +export type Static<T extends AnySchema> = T["infer"]; +export type TAny = TTyped<unknown>; +export type TUnknown = TTyped<unknown>; +export type TNever = TTyped<never>; +export type TNull = TTyped<null>; +export type TString = TTyped<string>; +export type TNumber = TTyped<number>; +export type TInteger = TTyped<number>; +export type TBoolean = TTyped<boolean>; +export type TLiteral<V extends string | number | boolean | null> = TTyped<V>; +export type TArray<E extends AnySchema> = TTyped<Static<E>[]>; +export type TTuple<E extends readonly AnySchema[] = readonly AnySchema[]> = TTyped<{ + -readonly [K in keyof E]: Static<E[K]>; +}>; +export type TOptional<E extends AnySchema> = TTyped<Static<E> | undefined> & { readonly [OPTIONAL_INNER]: E }; +export type TUnion<E extends readonly AnySchema[] = readonly AnySchema[]> = TTyped<Static<E[number]>>; +export type TIntersect<E extends readonly AnySchema[] = readonly AnySchema[]> = TTyped< + UnionToIntersection<Static<E[number]>> +>; +export type TEnum<E extends readonly (string | number)[] = readonly (string | number)[]> = TTyped<E[number]>; +export type TRecord<K extends AnySchema, V extends AnySchema> = TTyped< + Record<Extract<Static<K>, PropertyKey>, Static<V>> +>; +export type TNullable<E extends AnySchema> = TTyped<Static<E> | null>; +export type TReadonly<E extends AnySchema> = TTyped<Readonly<Static<E>>>; +export type TUnsafe<T = unknown> = TTyped<T>; + +type OptionalKeys<P extends Record<string, AnySchema>> = { + [K in keyof P]-?: P[K] extends { readonly [OPTIONAL_INNER]: AnySchema } ? K : never; +}[keyof P]; +type RequiredKeys<P extends Record<string, AnySchema>> = Exclude<keyof P, OptionalKeys<P>>; +type ObjectStatic<P extends Record<string, AnySchema>> = { + [K in RequiredKeys<P>]: Static<P[K]>; +} & { + [K in OptionalKeys<P>]?: Exclude<Static<P[K]>, undefined>; +}; +export type TObject<P extends Record<string, AnySchema> = Record<string, AnySchema>> = TTyped<ObjectStatic<P>>; +type RequiredProps<P extends Record<string, AnySchema>> = { + [K in keyof P]: P[K] extends TOptional<infer E> ? E : P[K]; +}; + +interface RuntimeType<T> extends OmpType<T> { + [OPTIONAL_INNER]?: AnySchema; + [OBJECT_INFO]?: ObjectInfo; + describe(description: string): RuntimeType<T>; + default(value: T | (() => T)): RuntimeType<T>; + or<schema extends AnySchema>(schema: schema): RuntimeType<T | Static<schema>>; + and<schema extends AnySchema>(schema: schema): RuntimeType<T & Static<schema>>; + array(): RuntimeType<T[]>; + atLeastLength(bound: number): RuntimeType<T>; + atMostLength(bound: number): RuntimeType<T>; + atLeast(bound: number): RuntimeType<T>; + atMost(bound: number): RuntimeType<T>; + narrow<N extends T>(predicate: (value: T, ctx: NarrowContext) => value is N): RuntimeType<N>; + narrow(predicate: (value: T, ctx: NarrowContext) => boolean): RuntimeType<T>; +} +type CompatRuntime<T> = RuntimeType<T> & LegacyTypeBoxCompat<T>; + +type ObjectInfo = { + props: Record<string, AnySchema>; + additionalProperties?: boolean | AnySchema; +}; + +function asRuntime<T>(schema: AnySchema): RuntimeType<T> { + return schema as unknown as RuntimeType<T>; +} + +function asSchema<T>(schema: AnySchema): TTyped<T> { + return schema as unknown as TTyped<T>; +} + +function validationFailure(message: string): TypeBoxValidationFailure { + return { message }; +} + +function withLegacyCompat<T>(schema: OmpType<T>): CompatRuntime<T> { + const compatSchema = schema as unknown as CompatRuntime<T>; + 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<T> => { + 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<T>(schema: RuntimeType<T>, opts?: Meta): CompatRuntime<T> { + 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<T>(schema: CompatRuntime<T>, keywords: Record<string, unknown>): CompatRuntime<T> { + const emitBase = schema.toJsonSchema.bind(schema); + 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 = asRuntime<string>(type.raw(opts?.format === "url" || opts?.format === "uri" ? "string.url" : "string")); + 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 = asRuntime<number>(type.raw(`${lowerDsl}${keyword}${upperDsl}`)); + 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<const V extends string | number | boolean | null>(value: V, opts?: Meta): TLiteral<V> { + return applyMeta(asRuntime<V>(type.enumerated(value)), opts); +} + +function tNever(opts?: Meta): TNever { + return applyMeta( + asRuntime<unknown>(type.raw("unknown")).narrow((_value, ctx): _value is never => ctx.mustBe("never")), + opts, + ); +} + +function tUnion<const E extends readonly AnySchema[]>(schemas: E, opts?: Meta): TUnion<E> { + if (schemas.length === 0) return asSchema<Static<E[number]>>(tNever(opts)); + let result = asRuntime<unknown>(schemas[0]); + for (let i = 1; i < schemas.length; i++) result = result.or(schemas[i]); + return asSchema<Static<E[number]>>(applyMeta(result, opts)); +} + +function tIntersect<const E extends readonly AnySchema[]>( + schemas: E, + opts?: Meta, +): TTyped<UnionToIntersection<Static<E[number]>>> { + if (schemas.length === 0) { + return applyMeta(asRuntime<UnionToIntersection<Static<E[number]>>>(type.raw("unknown")), opts); + } + const validateAll = (): RuntimeType<UnionToIntersection<Static<E[number]>>> => { + const base = asRuntime<unknown>(type.raw("unknown")); + return base.narrow((value, ctx): value is UnionToIntersection<Static<E[number]>> => { + 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 = asRuntime<unknown>(schemas[0]); + 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 asSchema<UnionToIntersection<Static<E[number]>>>(applyMeta(result, opts)); +} +type UnionToIntersection<U> = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void + ? I + : never; + +function enumValues(values: Record<string, string | number> | readonly (string | number)[]): (string | number)[] { + if (Array.isArray(values)) return [...values]; + const result: (string | number)[] = []; + const record = values as Record<string, string | number>; + 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<const E extends Record<string, string | number> | readonly (string | number)[]>( + values: E, + opts?: Meta, +): TTyped<E extends readonly (infer V)[] ? V : E[keyof E]> { + return applyMeta( + asRuntime<E extends readonly (infer V)[] ? V : E[keyof E]>(type.enumerated(...enumValues(values))), + opts, + ); +} + +function tArray<E extends AnySchema>(item: E, opts?: ArrayOpts): TArray<E> { + checkFiniteOption("minItems", opts?.minItems); + checkFiniteOption("maxItems", opts?.maxItems); + let schema = asRuntime<Static<E>>(item).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<const E extends readonly AnySchema[]>(items: E, opts?: Meta): TTuple<E> { + const schema = asRuntime<unknown>(type.raw("unknown")).narrow( + (value, ctx): value is { -readonly [K in keyof E]: Static<E[K]> } => { + 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<const P extends Record<string, AnySchema>>(properties: P, opts?: ObjectOpts): TObject<P> { + const def: Record<string, Def> = {}; + const props: Record<string, AnySchema> = {}; + for (const key in properties) { + const schema = properties[key]; + const inner = asRuntime<unknown>(schema)[OPTIONAL_INNER]; + def[inner ? `${key}?` : key] = inner ?? schema; + props[key] = schema; + } + if (opts?.additionalProperties === false) def["+"] = "reject"; + else if (opts?.additionalProperties && opts.additionalProperties !== true) + def["[string]"] = opts.additionalProperties; + const schema = applyMeta(asRuntime<ObjectStatic<P>>(type.raw(def)), opts); + schema[OBJECT_INFO] = { props, additionalProperties: opts?.additionalProperties }; + return schema; +} + +function tRecord<K extends AnySchema, V extends AnySchema>(key: K, value: V, opts?: Meta): TRecord<K, V> { + const base = asRuntime<Record<string, Static<V>>>(type.raw({ "[string]": value })).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<K, V>; +} + +function tOptional<E extends AnySchema>(schema: E, opts?: Meta): TOptional<E> { + const marker = applyMeta( + asRuntime<Static<E>>(schema).or(asRuntime<undefined>(type.raw("undefined"))), + opts, + ) as RuntimeType<Static<E> | undefined>; + marker[OPTIONAL_INNER] = schema; + return marker as unknown as TOptional<E>; +} + +function tNullable<E extends AnySchema>(schema: E, opts?: Meta): TTyped<Static<E> | null> { + return applyMeta(asRuntime<Static<E>>(schema).or(asRuntime<null>(type.raw("null"))), opts); +} + +function requireObject(schema: AnySchema, operation: string): ObjectInfo { + const info = asRuntime<unknown>(schema)[OBJECT_INFO]; + if (!info) throw new OmpTypeError(`Type.${operation} requires a schema created by Type.Object`); + return info; +} + +function tPartial<P extends Record<string, AnySchema>>(schema: TObject<P>): TTyped<Partial<ObjectStatic<P>>> { + const info = requireObject(schema, "Partial"); + const props: Record<string, AnySchema> = {}; + for (const key in info.props) + props[key] = asRuntime<unknown>(info.props[key])[OPTIONAL_INNER] ? info.props[key] : tOptional(info.props[key]); + return tObject(props, { additionalProperties: info.additionalProperties }) as TTyped<Partial<ObjectStatic<P>>>; +} + +function tRequired<P extends Record<string, AnySchema>>(schema: TObject<P>): TObject<RequiredProps<P>> { + const info = requireObject(schema, "Required"); + const props: Record<string, AnySchema> = {}; + for (const key in info.props) { + props[key] = asRuntime<unknown>(info.props[key])[OPTIONAL_INNER] ?? info.props[key]; + } + return tObject(props, { additionalProperties: info.additionalProperties }) as TObject<RequiredProps<P>>; +} + +function tPick<P extends Record<string, AnySchema>, const K extends readonly (keyof P)[]>( + schema: TObject<P>, + keys: K, +): TObject<Pick<P, K[number]>> { + const info = requireObject(schema, "Pick"); + const props: Record<string, AnySchema> = {}; + 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<Pick<P, K[number]>>; +} + +function tOmit<P extends Record<string, AnySchema>, const K extends readonly (keyof P)[]>( + schema: TObject<P>, + keys: K, +): TObject<Omit<P, K[number]>> { + const info = requireObject(schema, "Omit"); + const omitted = new Set<PropertyKey>(keys); + const props: Record<string, AnySchema> = {}; + for (const key in info.props) if (!omitted.has(key)) props[key] = info.props[key]; + return tObject(props, { additionalProperties: info.additionalProperties }) as TObject<Omit<P, K[number]>>; +} + +function tComposite<const E extends readonly TObject<Record<string, AnySchema>>[]>( + schemas: E, + opts?: ObjectOpts, +): TTyped<UnionToIntersection<Static<E[number]>>> { + const props: Record<string, AnySchema> = {}; + for (const schema of schemas) Object.assign(props, requireObject(schema, "Composite").props); + return asSchema<UnionToIntersection<Static<E[number]>>>(tObject(props, opts)); +} + +function tUnsafe<T = unknown>(_jsonSchema: Record<string, unknown> = {}): TUnsafe<T> { + // 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<T>); +} + +export const Type = { + String: tString, + Number: (opts?: NumberOpts) => tNumber(opts), + Integer: (opts?: NumberOpts) => tNumber(opts, true), + Boolean: (opts?: Meta) => applyMeta(asRuntime<boolean>(type.raw("boolean")), opts), + Null: (opts?: Meta) => applyMeta(asRuntime<null>(type.raw("null")), opts), + Any: (opts?: Meta) => applyMeta(asRuntime<unknown>(type.raw("unknown")), opts), + Unknown: (opts?: Meta) => applyMeta(asRuntime<unknown>(type.raw("unknown")), opts), + Never: tNever, + Literal: tLiteral, + Union: tUnion, + Intersect: tIntersect, + Enum: tEnum, + Array: tArray, + Tuple: tTuple, + Object: tObject, + Record: tRecord, + Optional: tOptional, + Nullable: tNullable, + Readonly: <E extends AnySchema>(schema: E): TReadonly<E> => + asSchema<Readonly<Static<E>>>(withLegacyCompat(asRuntime<Readonly<Static<E>>>(schema))), + 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..02aff3209 --- /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<out Out> extends EmbeddableSchema { + (value: unknown): Out | OmpErrors; + narrow(predicate: (value: Out, context: NarrowContext) => unknown): Decoratable<Out>; + pipe<Next>(transform: (value: Out, context: NarrowContext) => Next): Decoratable<Exclude<Next, OmpErrors>>; + or(def: unknown): Decoratable<unknown>; + describe(description: string): Decoratable<Out>; + default(value: Out | (() => Out)): Decoratable<Out>; +} + +export interface ZodLikeIssue { + path: PropertyKey[]; + message: string; +} + +export type ZodLikeSafeParseResult<Out> = + | { 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<out Out> extends Type<Out, unknown> { + readonly _output: Out; + /** @internal Used while composing object property IR. */ + readonly isOptional: boolean; + parse(value: unknown): Out; + safeParse(value: unknown): ZodLikeSafeParseResult<Out>; + min(bound: number): ZodLikeSchema<Out>; + max(bound: number): ZodLikeSchema<Out>; + int(): ZodLikeSchema<Out>; + positive(): ZodLikeSchema<Out>; + nonnegative(): ZodLikeSchema<Out>; + regex(expression: RegExp, message?: string): ZodLikeSchema<Out>; + url(): ZodLikeSchema<Out>; + optional(): ZodLikeSchema<Out | undefined> & OptionalSchemaMarker; + nullable(): ZodLikeSchema<Out | null>; + default(value: Exclude<Out, undefined> | (() => Exclude<Out, undefined>)): ZodLikeSchema<Exclude<Out, undefined>>; + describe(description: string): ZodLikeSchema<Out>; + refine(predicate: (value: Out) => unknown, messageOrOptions?: string | RefineOptions): ZodLikeSchema<Out>; + transform<Next>(transformer: (value: Out) => Next): ZodLikeSchema<Next>; + catch(fallback: Out | (() => Out)): ZodLikeSchema<Out>; + strict(): ZodLikeSchema<Out>; + passthrough(): ZodLikeSchema<Out & Record<string, unknown>>; + strip(): ZodLikeSchema<Out>; + partial(): Out extends object ? ZodLikeSchema<Partial<Out>> : ZodLikeSchema<Out>; +} + +function schemaFromIR<Out>(ir: IR): Decoratable<Out> { + const embedded: EmbeddableSchema = { + [IR_BRAND]: true, + ir, + hasSteps: false, + hasDefault: false, + run: value => value, + }; + return type.raw(embedded) as unknown as Decoratable<Out>; +} + +function restrictBase<Out>(source: Decoratable<Out>, ir: IR): Decoratable<Out> { + let next = source.hasSteps + ? schemaFromIR<Out>({ k: "morph", input: ir, fn: value => source(value) }) + : schemaFromIR<Out>(ir); + if (source.ir.desc !== undefined) next = next.describe(source.ir.desc); + if (source.hasDefault) next = next.default(source.defaultValue as Out | (() => Out)); + return next; +} + +function lengthBound(kind: "min" | "max", schema: Decoratable<unknown>, 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<Out>(schema: Decoratable<Out>, optional = false): ZodLikeSchema<Out> { + const next = (inner: Decoratable<Out>, nextOptional = optional): ZodLikeSchema<Out> => decorate(inner, nextOptional); + const withObjectExtras = (extras: "keep" | "reject" | "delete"): ZodLikeSchema<Out> => { + 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<Out> { + 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<Out> { + 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<Out> { + 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<Out> { + 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<Out> { + 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<Out> { + 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<Out> { + 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<Out> { + 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<Out | undefined> & OptionalSchemaMarker { + const widened = schema.or(type.raw("undefined")) as Decoratable<Out | undefined>; + return decorate(widened, true) as ZodLikeSchema<Out | undefined> & OptionalSchemaMarker; + }, + nullable(): ZodLikeSchema<Out | null> { + return decorate(schema.or(type.raw("null")) as Decoratable<Out | null>, optional); + }, + default( + value: Exclude<Out, undefined> | (() => Exclude<Out, undefined>), + ): ZodLikeSchema<Exclude<Out, undefined>> { + type DefaultOut = Exclude<Out, undefined>; + const widened = schema.or(type.raw("undefined")) as Decoratable<Out | undefined>; + const piped = widened.pipe(output => { + if (output !== undefined) return output as DefaultOut; + return typeof value === "function" ? (value as () => DefaultOut)() : value; + }) as Decoratable<DefaultOut>; + return decorate(piped.default(value as DefaultOut | (() => DefaultOut))); + }, + describe(description: string): ZodLikeSchema<Out> { + return next(restrictBase(schema, { ...schema.ir, desc: description }).describe(description)); + }, + refine(predicate: (value: Out) => unknown, messageOrOptions?: string | RefineOptions): ZodLikeSchema<Out> { + const expectation = refinementMessage(messageOrOptions); + return next(schema.narrow((value, ctx) => Boolean(predicate(value)) || ctx.mustBe(expectation))); + }, + transform<Next>(transformer: (value: Out) => Next): ZodLikeSchema<Next> { + return decorate( + schema.pipe(value => transformer(value)), + optional, + ); + }, + catch(fallback: Out | (() => Out)): ZodLikeSchema<Out> { + 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<Out>, optional); + }, + strict(): ZodLikeSchema<Out> { + return withObjectExtras("reject"); + }, + passthrough(): ZodLikeSchema<Out & Record<string, unknown>> { + return withObjectExtras("keep") as ZodLikeSchema<Out & Record<string, unknown>>; + }, + strip(): ZodLikeSchema<Out> { + return withObjectExtras("delete"); + }, + partial(): Out extends object ? ZodLikeSchema<Partial<Out>> : ZodLikeSchema<Out> { + 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<Partial<Out>> + : ZodLikeSchema<Out>; + }, + }) as unknown as ZodLikeSchema<Out>; +} + +function decorateUnknown(schema: Decoratable<unknown>): ZodLikeSchema<unknown> { + return decorate(schema); +} + +export type infer<T> = T extends { readonly _output: infer Out } ? Out : never; + +type SchemaOutput<Schema> = Schema extends { readonly _output: infer Out } ? Out : never; +type Shape = Readonly<Record<string, ZodLikeSchema<unknown>>>; +type ObjectOutput<S extends Shape> = { + -readonly [K in keyof S as S[K] extends OptionalSchemaMarker ? never : K]: SchemaOutput<S[K]>; +} & { + -readonly [K in keyof S as S[K] extends OptionalSchemaMarker ? K : never]?: SchemaOutput<S[K]>; +}; +type Simplify<T> = { [K in keyof T]: T[K] }; +type UnionOutput<Schemas extends readonly ZodLikeSchema<unknown>[]> = SchemaOutput<Schemas[number]>; + +function objectSchema<const S extends Shape>(shape: S): ZodLikeSchema<ObjectOutput<S>> { + 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<unknown>({ k: "object", props, extras: "delete" })) as unknown as ZodLikeSchema< + ObjectOutput<S> + >; +} + +export const string = (): ZodLikeSchema<string> => decorate(schemaFromIR(type.string.ir)); +export const number = (): ZodLikeSchema<number> => decorate(schemaFromIR(type.number.ir)); +export const boolean = (): ZodLikeSchema<boolean> => decorate(schemaFromIR(type.boolean.ir)); +export const literal = <const Value>(value: Value): ZodLikeSchema<Value> => + decorate(schemaFromIR<Value>(type.enumerated(value).ir)); +const enumSchema = <const Values extends readonly [string, ...string[]]>( + values: Values, +): ZodLikeSchema<Values[number]> => { + if (values.length === 0) throw new OmpTypeError("enum requires at least one value"); + return decorate(schemaFromIR<Values[number]>(type.enumerated(...values).ir)); +}; + +export { enumSchema as enum }; +export const union = < + const Schemas extends readonly [ZodLikeSchema<unknown>, ZodLikeSchema<unknown>, ...ZodLikeSchema<unknown>[]], +>( + schemas: Schemas, +): ZodLikeSchema<UnionOutput<Schemas>> => + decorate(schemaFromIR({ k: "union", members: schemas.map(schema => embed(schema)) })); +export const array = <Element>(element: ZodLikeSchema<Element>): ZodLikeSchema<Element[]> => + decorate(schemaFromIR({ k: "array", el: embed(element) })); +export const object = <const S extends Shape>(shape: S): ZodLikeSchema<Simplify<ObjectOutput<S>>> => + objectSchema(shape); +export const record = <Key extends string, Value>( + keySchema: ZodLikeSchema<Key>, + valueSchema: ZodLikeSchema<Value>, +): ZodLikeSchema<Record<string, Value>> => { + if (!isStringKeyIR(keySchema.ir)) throw new OmpTypeError("record keys must use a string schema"); + const base = schemaFromIR<Record<string, Value>>({ + 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<unknown> => decorate(schemaFromIR(type.unknown.ir)); +export const any = (): ZodLikeSchema<unknown> => decorate(schemaFromIR(type.unknown.ir)); +const nullSchema = (): ZodLikeSchema<null> => decorate(type.raw("null") as unknown as Decoratable<null>); +const undefinedSchema = (): ZodLikeSchema<undefined> => + decorate(type.raw("undefined") as unknown as Decoratable<undefined>); + +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> = Schema extends { readonly _output: infer Out } ? Out : never; +} diff --git a/packages/omptype/test/ark/LICENSE b/packages/omptype/test/ark/LICENSE new file mode 100644 index 000000000..b78b3fd64 --- /dev/null +++ b/packages/omptype/test/ark/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 ArkType + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/omptype/test/ark/arrays/array.test.ts b/packages/omptype/test/ark/arrays/array.test.ts new file mode 100644 index 000000000..22e95892a --- /dev/null +++ b/packages/omptype/test/ark/arrays/array.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +describe("non-tuple", () => { + it("allows and apply", () => { + const T = type("string[]"); + const _0: Eq<typeof T.infer, string[]> = true; + expect(T.allows([])).toBe(true); + expect(T([])).toEqual([]); + expect(T.allows(["foo", "bar"])).toBe(true); + expect(T(["foo", "bar"])).toEqual(["foo", "bar"]); + expect(T.allows(["foo", "bar", 5])).toBe(false); + expect(String(T(["foo", "bar", 5]))).toBe("[2] must be a string (was a number)"); + expect(T.allows([5, "foo", "bar"])).toBe(false); + expect(String(T([5, "foo", "bar"]))).toBe("[0] must be a string (was a number)"); + }); + + it("nested", () => { + const T = type("string[][]"); + const _0: Eq<typeof T.infer, string[][]> = true; + expect(T.allows([])).toBe(true); + expect(T([])).toEqual([]); + expect(T.allows([["foo"]])).toBe(true); + expect(T([["foo"]])).toEqual([["foo"]]); + expect(T.allows(["foo"])).toBe(false); + expect(String(T(["foo"]))).toBe("[0] must be an array (was a string)"); + expect(T.allows([["foo", 5]])).toBe(false); + expect(String(T([["foo", 5]]))).toBe("[0][1] must be a string (was a number)"); + }); + + it("tuple expression", () => { + const T = type(["string", "[]"]); + const _0: Eq<typeof T.infer, string[]> = true; + expect(T.json).toEqual(type("string[]").json); + }); + + it("root expression", () => { + const T = type("string", "[]"); + const _0: Eq<typeof T.infer, string[]> = true; + expect(T.json).toEqual(type("string[]").json); + }); + + it("chained", () => { + const T = type({ a: "string" }).array(); + const _0: Eq<typeof T.infer, { a: string }[]> = true; + + expect(() => type({ a: "hmm" }).array()).toThrow('unknown keyword "hmm" in "hmm"'); + }); + + it("incomplete token", () => { + expect(() => type("string[")).toThrow("expected ']' in \"string[\""); + }); +}); + +describe("non-variadic tuple", () => { + it("empty", () => { + const T = type([]); + const _0: Eq<typeof T.infer, []> = true; + expect(T([])).toEqual([]); + expect(String(T([1]))).toBe("must be an array of at most length 0 (was an array)"); + }); + + it("shallow", () => { + const T = type(["string", "number"]); + const _0: Eq<typeof T.infer, [string, number]> = true; + expect(T.allows(["", 0])).toBe(true); + expect(T(["", 0])).toEqual(["", 0]); + expect(T.allows([true, 0])).toBe(false); + expect(String(T([true, 0]))).toBe("[0] must be a string (was boolean)"); + expect(T.allows([0, false])).toBe(false); + expect(String(T([0, false]))).toBe(`[0] must be a string (was a number) +[1] must be a number (was boolean)`); + // too short + expect(T.allows([""])).toBe(false); + expect(String(T([""]))).toBe("must be an array of at least length 2 (was an array)"); + // too long + expect(T.allows(["", 0, 1])).toBe(false); + expect(String(T(["", 0, 1]))).toBe("must be an array of at most length 2 (was an array)"); + // non-array + expect( + T.allows({ + length: 2, + 0: "", + 1: 0, + }), + ).toBe(false); + expect( + String( + T({ + length: 2, + 0: "", + 1: 0, + }), + ), + ).toBe("must be an array (was an object)"); + }); + + it("nested", () => { + const T = type([["string", "number"], [{ a: "bigint", b: ["null"] }]]); + const _0: Eq< + typeof T.infer, + [ + [string, number], + [ + { + a: bigint; + b: [null]; + }, + ], + ] + > = true; + const valid: typeof T.infer = [["", 0], [{ a: 0n, b: [null] }]]; + expect(T.allows(valid)).toBe(true); + expect(T(valid)).toEqual(valid); + const invalid = [["", 0], [{ a: 0n, b: [undefined] }]]; + expect(T.allows(invalid)).toBe(false); + expect(String(T(invalid))).toBe("[1][0].b[0] must be null (was undefined)"); + }); + + it("optional tuple", () => { + const T = type([["string", "?"]]); + const _0: Eq<typeof T.infer, [string?]> = true; + expect(T([])).toEqual([]); + expect(T(["foo"])).toEqual(["foo"]); + expect(String(T([5]))).toBe("[0] must be a string (was a number)"); + expect(String(T(["foo", "bar"]))).toBe("must be an array of at most length 1 (was an array)"); + }); + + it("optional string-embedded tuple", () => { + const T = type(["string?"]); + + const Expected = type([["string", "?"]]); + const _0: Eq<typeof T, typeof Expected> = true; + expect(T([])).toEqual([]); + expect(T(["foo"])).toEqual(["foo"]); + }); + + it("optional object tuple", () => { + const T = type([[{ foo: "string" }, "?"], "string?"]); + const _0: Eq< + typeof T.t, + [ + { + foo: string; + }?, + string?, + ] + > = true; + expect(T([])).toEqual([]); + expect(T([{ foo: "bar" }])).toEqual([{ foo: "bar" }]); + }); + + it("optional nested object tuple", () => { + const T = type([[[{ foo: "string" }, "?"]], ["string", "?"]]); + const _0: Eq< + typeof T.t, + [ + [ + { + foo: string; + }?, + ], + string?, + ] + > = true; + expect(T([[{ foo: "bar" }]])).toEqual([[{ foo: "bar" }]]); + expect(T([[{ foo: "bar" }], "baz"])).toEqual([[{ foo: "bar" }], "baz"]); + }); +}); + +describe("variadic tuple", () => { + it("spreads simple arrays", () => { + const WellRested = type(["string", "...", "number[]"]); + const _0: Eq<typeof WellRested.infer, [string, ...number[]]> = true; + expect(WellRested(["foo"])).toEqual(["foo"]); + expect(WellRested(["foo", 1, 2])).toEqual(["foo", 1, 2]); + }); + + it("spreads array expressions", () => { + const GreatSpread = type(["0", "...", "(Date|RegExp)[]"]); + const _0: Eq<typeof GreatSpread.infer, [0, ...(RegExp | Date)[]]> = true; + }); + + it("distributes spread unions", () => { + const T = type(["1", "...", "(Date[] | RegExp[])"]); + const _0: Eq<typeof T.infer, [1, ...(Date[] | RegExp[])]> = true; + expect(T.allows([1, new Date(), new Date()])).toBe(true); + expect(T.allows([1, /foo/])).toBe(true); + expect(T.allows([1, new Date(), /foo/])).toBe(false); + }); + + it("distributes spread union tuples", () => { + const counting = ["2", "3", "4"] as const; + const fibbing = ["1", "2", "3", "5", "8"] as const; + const CountOrFib = type(counting, "|", fibbing); + const _0: Eq<typeof CountOrFib.infer, [2, 3, 4] | [1, 2, 3, 5, 8]> = true; + const T = type(["1", "...", CountOrFib]); + const _1: Eq<typeof T.infer, [1, 2, 3, 4] | [1, 1, 2, 3, 5, 8]> = true; + expect(T.allows([1, 2, 3, 4])).toBe(true); + expect(T.allows([1, 1, 2, 3, 5, 8])).toBe(true); + expect(T.allows([1, 2, 3])).toBe(false); + }); + + it("allows array keyword", () => { + const types = scope({ + myArrayKeyword: "boolean[]", + myVariadicKeyword: ["string", "...", "myArrayKeyword"], + }).export(); + const _0: Eq<typeof types.myVariadicKeyword.infer, [string, ...boolean[]]> = true; + }); + + it("errors on non-array", () => { + expect(() => type(["number", "...", "string"])).toThrow("tuple spread element must be an array"); + }); + + it("allows multiple fixed spreads", () => { + const T = type(["string", "...", "number[]", "...", ["boolean", "bigint"], "...", ["symbol"]]); + const Expected = type(["string", "...", "number[]", "boolean", "bigint", "symbol"]); + const _0: Eq<typeof T.infer, [string, ...number[], boolean, bigint, symbol]> = true; + const _1: Eq<typeof Expected.infer, typeof T.infer> = true; + expect(T.allows(["foo", 1, 2, true, 3n, Symbol.iterator])).toBe(true); + expect(T.allows(["foo", true, 3n, Symbol.iterator])).toBe(true); + expect(T.allows(["foo", 1, true, Symbol.iterator])).toBe(false); + }); + + it("errors on multiple variadic", () => { + expect(() => type(["...", "string[]", "...", "number[]"])).toThrow( + "a tuple may have one spread followed by an array definition", + ); + }); +}); + +it("reduces minLength", () => { + const T = type(["number", "number", "...", "number[]", "number"]); + expect(T.allows([1, 2, 3])).toBe(true); + expect(T.allows([1, 2])).toBe(false); + expect(T.allows([1, 2, "3"])).toBe(false); +}); + +it("readonly arrays and tuples", () => { + const ReadonlyArray = type("string[]").readonly(); + const ReadonlyTuple = type(["string", "number"]).readonly(); + const _0: Eq<typeof ReadonlyArray.infer, readonly string[]> = true; + const _1: Eq<typeof ReadonlyTuple.infer, readonly [string, number]> = true; + expect(ReadonlyArray(["foo"])).toEqual(["foo"]); + expect(ReadonlyTuple(["foo", 1])).toEqual(["foo", 1]); +}); + +it("multiple errors", () => { + const StringArray = type("string[]"); + expect(StringArray([1, 2]).toString()).toBe(`[0] must be a string (was a number) +[1] must be a string (was a number)`); +}); diff --git a/packages/omptype/test/ark/arrays/base.test.ts b/packages/omptype/test/ark/arrays/base.test.ts new file mode 100644 index 000000000..0ca25864c --- /dev/null +++ b/packages/omptype/test/ark/arrays/base.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("allows and apply", () => { + const T = type("string[]"); + const _0: Eq<typeof T.infer, string[]> = true; + expect(T.allows([])).toBe(true); + expect(T([])).toEqual([]); + expect(T.allows(["foo", "bar"])).toBe(true); + expect(T(["foo", "bar"])).toEqual(["foo", "bar"]); + expect(T.allows(["foo", "bar", 5])).toBe(false); + expect(String(T(["foo", "bar", 5]))).toBe("[2] must be a string (was a number)"); + expect(T.allows([5, "foo", "bar"])).toBe(false); + expect(String(T([5, "foo", "bar"]))).toBe("[0] must be a string (was a number)"); +}); + +it("nested", () => { + const T = type("string[][]"); + const _0: Eq<typeof T.infer, string[][]> = true; + expect(T.allows([])).toBe(true); + expect(T([])).toEqual([]); + expect(T.allows([["foo"]])).toBe(true); + expect(T([["foo"]])).toEqual([["foo"]]); + expect(T.allows(["foo"])).toBe(false); + expect(String(T(["foo"]))).toBe("[0] must be an array (was a string)"); + expect(T.allows([["foo", 5]])).toBe(false); + expect(String(T([["foo", 5]]))).toBe("[0][1] must be a string (was a number)"); +}); + +it("tuple expression", () => { + const T = type(["string", "[]"]); + const _0: Eq<typeof T.infer, string[]> = true; + expect(T.json).toEqual(type("string[]").json); +}); + +it("root expression", () => { + const T = type("string", "[]"); + const _0: Eq<typeof T.infer, string[]> = true; + expect(T.json).toEqual(type("string[]").json); +}); + +it("chained", () => { + const T = type({ a: "string" }).array(); + const _0: Eq<typeof T.infer, { a: string }[]> = true; + + expect(() => type({ a: "hmm" }).array()).toThrow('unknown keyword "hmm" in "hmm"'); +}); + +it("incomplete token", () => { + expect(() => type("string[")).toThrow("expected ']' in \"string[\""); +}); + +it("multiple errors", () => { + const StringArray = type("string[]"); + expect(StringArray([1, 2]).toString()).toBe(`[0] must be a string (was a number) +[1] must be a string (was a number)`); +}); diff --git a/packages/omptype/test/ark/arrays/defaults.test.ts b/packages/omptype/test/ark/arrays/defaults.test.ts new file mode 100644 index 000000000..79585ccdc --- /dev/null +++ b/packages/omptype/test/ark/arrays/defaults.test.ts @@ -0,0 +1,72 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("single element tuple", () => { + const T = type([["number", "=", 5]]); + expect(T.expression).toBe("[number = 5]"); + expect(T([])).toEqual([5]); + expect(T([1])).toEqual([1]); + expect(String(T([null]))).toBe("[0] must be a number (was null)"); + expect(String(T([1, 2]))).toBe("must be an array of at most length 1 (was an array)"); +}); + +it("string", () => { + const T = type(["string = 'foo'"]); + expect(T.expression).toBe('[string = "foo"]'); + expect(T([])).toEqual(["foo"]); + expect(T(["bar"])).toEqual(["bar"]); + expect(String(T([false]))).toBe("[0] must be a string (was boolean)"); + expect(String(T(["foo", "bar"]))).toBe("must be an array of at most length 1 (was an array)"); +}); + +it("defaults following prefix", () => { + const T = type(["string", "number = 5"]); + expect(T.expression).toBe("[string, number = 5]"); + expect(T([""])).toEqual(["", 5]); + expect(T(["", 7])).toEqual(["", 7]); + + expect(String(T([]))).toBe("must be an array of at least length 1 (was an array)"); + expect(String(T(["foo", "bar"]))).toBe("[1] must be a number (was a string)"); +}); + +it("defaults preceding variadic", () => { + const T = type(["number", "string = 'foo'", "...", "number[]"]); + expect(T.expression).toBe('[number, string = "foo", ...number[]]'); + + expect(T([5])).toEqual([5, "foo"]); + expect(T([7, "bar"])).toEqual([7, "bar"]); + expect(T([8, "bar", 5])).toEqual([8, "bar", 5]); + + expect(String(T([]))).toBe("must be an array of at least length 1 (was an array)"); + expect(String(T([5, 5]))).toBe("[1] must be a string (was a number)"); +}); + +it("default after undefaulted optional", () => { + expect(() => type(["number?", "number = 5"])).toThrow( + "A defaultable element may not follow an optional element without a default", + ); +}); + +it("input extracted as optional", () => { + const T = type(["number = 5"]); + const _0: Eq<typeof T.in.t, [number?]> = true; + const _1: Eq<typeof T.inferIn, [number?]> = true; + + expect(T.in.expression).toBe("[number?]"); +}); + +it("output extracted as required", () => { + const T = type(["number = 5"]); + const _0: Eq<typeof T.out.t, [number]> = true; + const _1: Eq<typeof T.inferOut, [number]> = true; + + expect(T.out.expression).toBe("[number]"); +}); + +it("compiled defaults use correct values", () => { + const T = type(["string = 'foo'"]); + + const result = T([]); + expect(result).toEqual(["foo"]); +}); diff --git a/packages/omptype/test/ark/arrays/intersection.test.ts b/packages/omptype/test/ark/arrays/intersection.test.ts new file mode 100644 index 000000000..f0989ee6d --- /dev/null +++ b/packages/omptype/test/ark/arrays/intersection.test.ts @@ -0,0 +1,265 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("shallow array intersection", () => { + const T = type("string[]&'foo'[]"); + const Expected = type("'foo'[]"); + expect(T.json).toEqual(Expected.json); +}); + +it("deep array intersection", () => { + const T = type([{ a: "string" }, "[]"]).and([{ b: "number" }, "[]"]); + const Expected = type([{ a: "string", b: "number" }, "[]"]); + expect(T.json).toEqual(Expected.json); +}); + +it("tuple intersection", () => { + const T = type([[{ a: "string" }], "&", [{ b: "boolean" }]]); + const Expected = type([{ a: "string", b: "boolean" }]); + const _0: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("tuple and array", () => { + const TupleAndArray = type([[{ a: "string" }], "&", [{ b: "boolean" }, "[]"]]); + const ArrayAndTuple = type([[{ b: "boolean" }, "[]"], "&", [{ a: "string" }]]); + + const Expected = type([{ a: "string", b: "boolean" }]); + const _0: Eq<typeof TupleAndArray, typeof Expected> = true; + + const _1: Eq<typeof ArrayAndTuple, typeof Expected> = true; + + expect(TupleAndArray.json).toEqual(Expected.json); + expect(ArrayAndTuple.json).toEqual(Expected.json); +}); + +it("variadic and tuple", () => { + const B = type([{ b: "boolean" }, "[]"]); + const T = type([{ a: "string" }, "...", B]).and([{ c: "number" }, { d: "Date" }]); + const Expected = type([ + { a: "string", c: "number" }, + { b: "boolean", d: "Date" }, + ]); + expect(T.json).toEqual(Expected.json); +}); + +it("variadic and array", () => { + const B = type({ b: "boolean" }, "[]"); + const T = type([{ a: "string" }, "...", B]).and([{ c: "number" }, "[]"]); + const Expected = type([{ a: "string", c: "number" }, "...", [{ b: "boolean", c: "number" }, "[]"]]); + const _0: Eq<typeof T.infer, typeof Expected.infer> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("kitchen sink", () => { + const L = type([{ a: "0" }, [{ b: "1" }, "?"], [{ c: "2" }, "?"], "...", [{ d: "3" }, "[]"]]); + const R = type([[{ e: "4" }, "?"], [{ f: "5" }, "?"], "...", [{ g: "6" }, "[]"]]); + const T = L.and(R); + + const Expected = type([ + { a: "0", e: "4" }, + [{ b: "1", f: "5" }, "?"], + [{ c: "2", g: "6" }, "?"], + "...", + [{ d: "3", g: "6" }, "[]"], + ]); + + expect(T.expression).toBe("[{ a: 0, e: 4 }, { b: 1, f: 5 }?, { c: 2, g: 6 }?, ...{ d: 3, g: 6 }[]]"); + + const _0: Eq<typeof T, typeof Expected> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("prefix and postfix", () => { + const L = type(["...", [{ a: "0" }, "[]"], { b: "0" }, { c: "0" }]); + const R = type([{ x: "0" }, { y: "0" }, "...", [{ z: "0" }, "[]"]]); + + const Expected = type([ + { a: "0", x: "0" }, + { a: "0", y: "0" }, + "...", + [{ a: "0", z: "0" }, "[]"], + { b: "0", z: "0" }, + { c: "0", z: "0" }, + ]) + .or([ + { a: "0", x: "0" }, + { b: "0", y: "0" }, + { c: "0", z: "0" }, + ]) + .or([ + { b: "0", x: "0" }, + { c: "0", y: "0" }, + ]); + + const lrResult = L.and(R); + const rlResult = R.and(L); + const validLong = [ + { a: 0, x: 0 }, + { a: 0, y: 0 }, + { b: 0, z: 0 }, + { c: 0, z: 0 }, + ]; + const validFixed = [ + { a: 0, x: 0 }, + { b: 0, y: 0 }, + { c: 0, z: 0 }, + ]; + const validShort = [ + { b: 0, x: 0 }, + { c: 0, y: 0 }, + ]; + const invalid = [ + { a: 0, x: 0 }, + { c: 0, y: 0 }, + ]; + for (const schema of [lrResult, rlResult, Expected]) { + expect(schema.allows(validLong)).toBe(true); + expect(schema.allows(validFixed)).toBe(true); + expect(schema.allows(validShort)).toBe(true); + expect(schema.allows(invalid)).toBe(false); + } +}); + +it("reduces minLength", () => { + const T = type(["number", "number", "...", "number[]", "number"]); + expect(T.allows([1, 2, 3])).toBe(true); + expect(T.allows([1, 2])).toBe(false); + expect(T.allows([1, 2, "3"])).toBe(false); +}); + +it("array with props", () => { + const T = type("Array").and({ name: "string" }); + + const _0: Eq< + typeof T.t, + unknown[] & { + name: string; + } + > = true; + + expect(String(T({ name: "foo" }))).toBe("must be an array (was an object)"); + const arrayWithProps = Object.assign([], { name: "foo" }); + expect(T(arrayWithProps)).toEqual(arrayWithProps); +}); + +it("shallow array intersection", () => { + const T = type("string[]&'foo'[]"); + const Expected = type("'foo'[]"); + expect(T.json).toEqual(Expected.json); +}); + +it("deep array intersection", () => { + const T = type([{ a: "string" }, "[]"]).and([{ b: "number" }, "[]"]); + const Expected = type([{ a: "string", b: "number" }, "[]"]); + expect(T.json).toEqual(Expected.json); +}); + +it("tuple intersection", () => { + const T = type([[{ a: "string" }], "&", [{ b: "boolean" }]]); + const Expected = type([{ a: "string", b: "boolean" }]); + const _0: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("tuple and array", () => { + const TupleAndArray = type([[{ a: "string" }], "&", [{ b: "boolean" }, "[]"]]); + const ArrayAndTuple = type([[{ b: "boolean" }, "[]"], "&", [{ a: "string" }]]); + + const Expected = type([{ a: "string", b: "boolean" }]); + const _0: Eq<typeof TupleAndArray, typeof Expected> = true; + + const _1: Eq<typeof ArrayAndTuple, typeof Expected> = true; + + expect(TupleAndArray.json).toEqual(Expected.json); + expect(ArrayAndTuple.json).toEqual(Expected.json); +}); + +it("variadic and tuple", () => { + const B = type([{ b: "boolean" }, "[]"]); + const T = type([{ a: "string" }, "...", B]).and([{ c: "number" }, { d: "Date" }]); + const Expected = type([ + { a: "string", c: "number" }, + { b: "boolean", d: "Date" }, + ]); + expect(T.json).toEqual(Expected.json); +}); + +it("variadic and array", () => { + const B = type({ b: "boolean" }, "[]"); + const T = type([{ a: "string" }, "...", B]).and([{ c: "number" }, "[]"]); + const Expected = type([{ a: "string", c: "number" }, "...", [{ b: "boolean", c: "number" }, "[]"]]); + const _0: Eq<typeof T.infer, typeof Expected.infer> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("kitchen sink", () => { + const L = type([{ a: "0" }, [{ b: "1" }, "?"], [{ c: "2" }, "?"], "...", [{ d: "3" }, "[]"]]); + const R = type([[{ e: "4" }, "?"], [{ f: "5" }, "?"], "...", [{ g: "6" }, "[]"]]); + const T = L.and(R); + + const Expected = type([ + { a: "0", e: "4" }, + [{ b: "1", f: "5" }, "?"], + [{ c: "2", g: "6" }, "?"], + "...", + [{ d: "3", g: "6" }, "[]"], + ]); + + expect(T.expression).toBe("[{ a: 0, e: 4 }, { b: 1, f: 5 }?, { c: 2, g: 6 }?, ...{ d: 3, g: 6 }[]]"); + + const _0: Eq<typeof T, typeof Expected> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("prefix and postfix", () => { + const L = type(["...", [{ a: "0" }, "[]"], { b: "0" }, { c: "0" }]); + const R = type([{ x: "0" }, { y: "0" }, "...", [{ z: "0" }, "[]"]]); + + const Expected = type([ + { a: "0", x: "0" }, + { a: "0", y: "0" }, + "...", + [{ a: "0", z: "0" }, "[]"], + { b: "0", z: "0" }, + { c: "0", z: "0" }, + ]) + .or([ + { a: "0", x: "0" }, + { b: "0", y: "0" }, + { c: "0", z: "0" }, + ]) + .or([ + { b: "0", x: "0" }, + { c: "0", y: "0" }, + ]); + + const LrResult = L.and(R); + const RlResult = R.and(L); + const validLong = [ + { a: 0, x: 0 }, + { a: 0, y: 0 }, + { b: 0, z: 0 }, + { c: 0, z: 0 }, + ]; + const validFixed = [ + { a: 0, x: 0 }, + { b: 0, y: 0 }, + { c: 0, z: 0 }, + ]; + const validShort = [ + { b: 0, x: 0 }, + { c: 0, y: 0 }, + ]; + const invalid = [ + { a: 0, x: 0 }, + { c: 0, y: 0 }, + ]; + for (const schema of [LrResult, RlResult, Expected]) { + expect(schema.allows(validLong)).toBe(true); + expect(schema.allows(validFixed)).toBe(true); + expect(schema.allows(validShort)).toBe(true); + expect(schema.allows(invalid)).toBe(false); + } +}); diff --git a/packages/omptype/test/ark/arrays/nonVariadicTuple.test.ts b/packages/omptype/test/ark/arrays/nonVariadicTuple.test.ts new file mode 100644 index 000000000..c4a46e5db --- /dev/null +++ b/packages/omptype/test/ark/arrays/nonVariadicTuple.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("empty", () => { + const T = type([]); + const _0: Eq<typeof T.infer, []> = true; + expect(T([])).toEqual([]); + expect(String(T([1]))).toBe("must be an array of at most length 0 (was an array)"); +}); + +it("shallow", () => { + const T = type(["string", "number"]); + const _0: Eq<typeof T.infer, [string, number]> = true; + expect(T.allows(["", 0])).toBe(true); + expect(T(["", 0])).toEqual(["", 0]); + expect(T.allows([true, 0])).toBe(false); + expect(String(T([true, 0]))).toBe("[0] must be a string (was boolean)"); + expect(T.allows([0, false])).toBe(false); + expect(String(T([0, false]))).toBe(`[0] must be a string (was a number) +[1] must be a number (was boolean)`); + // too short + expect(T.allows([""])).toBe(false); + expect(String(T([""]))).toBe("must be an array of at least length 2 (was an array)"); + // too long + expect(T.allows(["", 0, 1])).toBe(false); + expect(String(T(["", 0, 1]))).toBe("must be an array of at most length 2 (was an array)"); + // non-array + expect( + T.allows({ + length: 2, + 0: "", + 1: 0, + }), + ).toBe(false); + expect( + String( + T({ + length: 2, + 0: "", + 1: 0, + }), + ), + ).toBe("must be an array (was an object)"); +}); + +it("nested", () => { + const T = type([["string", "number"], [{ a: "bigint", b: ["null"] }]]); + const _0: Eq< + typeof T.infer, + [ + [string, number], + [ + { + a: bigint; + b: [null]; + }, + ], + ] + > = true; + const valid: typeof T.infer = [["", 0], [{ a: 0n, b: [null] }]]; + expect(T.allows(valid)).toBe(true); + expect(T(valid)).toEqual(valid); + const invalid = [["", 0], [{ a: 0n, b: [undefined] }]]; + expect(T.allows(invalid)).toBe(false); + expect(String(T(invalid))).toBe("[1][0].b[0] must be null (was undefined)"); +}); + +it("optional tuple", () => { + const T = type([["string", "?"]]); + const _0: Eq<typeof T.infer, [string?]> = true; + expect(T([])).toEqual([]); + expect(T(["foo"])).toEqual(["foo"]); + expect(String(T([5]))).toBe("[0] must be a string (was a number)"); + expect(String(T(["foo", "bar"]))).toBe("must be an array of at most length 1 (was an array)"); +}); + +it("optional string-embedded tuple", () => { + const T = type(["string?"]); + + const Expected = type([["string", "?"]]); + const _0: Eq<typeof T, typeof Expected> = true; + expect(T([])).toEqual([]); + expect(T(["foo"])).toEqual(["foo"]); +}); + +it("optional object tuple", () => { + const T = type([[{ foo: "string" }, "?"], "string?"]); + const _0: Eq< + typeof T.t, + [ + { + foo: string; + }?, + string?, + ] + > = true; + expect(T([])).toEqual([]); + expect(T([{ foo: "bar" }])).toEqual([{ foo: "bar" }]); +}); + +it("optional nested object tuple", () => { + const T = type([[[{ foo: "string" }, "?"]], ["string", "?"]]); + const _0: Eq< + typeof T.t, + [ + [ + { + foo: string; + }?, + ], + string?, + ] + > = true; + expect(T([[{ foo: "bar" }]])).toEqual([[{ foo: "bar" }]]); + expect(T([[{ foo: "bar" }], "baz"])).toEqual([[{ foo: "bar" }], "baz"]); +}); diff --git a/packages/omptype/test/ark/arrays/variadicTuple.test.ts b/packages/omptype/test/ark/arrays/variadicTuple.test.ts new file mode 100644 index 000000000..8ceacda3b --- /dev/null +++ b/packages/omptype/test/ark/arrays/variadicTuple.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("spreads simple arrays", () => { + const WellRested = type(["string", "...", "number[]"]); + const _0: Eq<typeof WellRested.infer, [string, ...number[]]> = true; + expect(WellRested(["foo"])).toEqual(["foo"]); + expect(WellRested(["foo", 1, 2])).toEqual(["foo", 1, 2]); +}); + +it("spreads array expressions", () => { + const GreatSpread = type(["0", "...", "(Date|RegExp)[]"]); + const _0: Eq<typeof GreatSpread.infer, [0, ...(RegExp | Date)[]]> = true; +}); + +it("distributes spread unions", () => { + const T = type(["1", "...", "(Date[] | RegExp[])"]); + const _0: Eq<typeof T.infer, [1, ...(Date[] | RegExp[])]> = true; + expect(T.allows([1, new Date(), new Date()])).toBe(true); + expect(T.allows([1, /foo/])).toBe(true); + expect(T.allows([1, new Date(), /foo/])).toBe(false); +}); + +it("distributes spread union tuples", () => { + const counting = ["2", "3", "4"] as const; + const fibbing = ["1", "2", "3", "5", "8"] as const; + const CountOrFib = type(counting, "|", fibbing); + const _0: Eq<typeof CountOrFib.infer, [2, 3, 4] | [1, 2, 3, 5, 8]> = true; + const T = type(["1", "...", CountOrFib]); + const _1: Eq<typeof T.infer, [1, 2, 3, 4] | [1, 1, 2, 3, 5, 8]> = true; + expect(T.allows([1, 2, 3, 4])).toBe(true); + expect(T.allows([1, 1, 2, 3, 5, 8])).toBe(true); + expect(T.allows([1, 2, 3])).toBe(false); +}); + +it("allows array keyword", () => { + const types = scope({ + myArrayKeyword: "boolean[]", + myVariadicKeyword: ["string", "...", "myArrayKeyword"], + }).export(); + const _0: Eq<typeof types.myVariadicKeyword.infer, [string, ...boolean[]]> = true; +}); + +it("errors on non-array", () => { + expect(() => type(["number", "...", "string"])).toThrow("tuple spread element must be an array"); +}); + +it("allows multiple fixed spreads", () => { + const T = type(["string", "...", "number[]", "...", ["boolean", "bigint"], "...", ["symbol"]]); + const _0: Eq<typeof T.infer, [string, ...number[], boolean, bigint, symbol]> = true; + expect(T.allows(["foo", 1, 2, true, 3n, Symbol.iterator])).toBe(true); + expect(T.allows(["foo", true, 3n, Symbol.iterator])).toBe(true); + expect(T.allows(["foo", 1, true, Symbol.iterator])).toBe(false); +}); + +it("errors on multiple variadic", () => { + expect(() => type(["...", "string[]", "...", "number[]"])).toThrow( + "a tuple may have one spread followed by an array definition", + ); +}); + +it("error on optional post-variadic in spread", () => { + expect(() => type(["...", "string[]", "...", ["string?"]])).toThrow( + "An optional element may not follow a variadic element", + ); +}); + +it("errors on postfix following optional", () => { + expect(() => type(["number?", "...", "boolean[]", "symbol"])).toThrow( + "A postfix required element cannot follow an optional or defaultable element", + ); +}); + +it("errors on postfix following defaultable", () => { + expect(() => type(["number = 0", "...", "boolean[]", "symbol"])).toThrow( + "A postfix required element cannot follow an optional or defaultable element", + ); +}); + +it("doesn't mistake a string literal containing '=' for defaultable", () => { + const T = type(["'='", "number"]); + + const _0: Eq<typeof T.t, ["=", number]> = true; + expect(T(["=", 5])).toEqual(["=", 5]); +}); diff --git a/packages/omptype/test/ark/badDefinitionType.test.ts b/packages/omptype/test/ark/badDefinitionType.test.ts new file mode 100644 index 000000000..992882a9a --- /dev/null +++ b/packages/omptype/test/ark/badDefinitionType.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("undefined", () => { + expect(() => type({ bad: undefined })).toThrow(); +}); + +it("null", () => { + expect(() => type({ bad: null })).toThrow(); +}); + +it("boolean", () => { + expect(() => type({ bad: true })).toThrow(); +}); + +it("number", () => { + expect(() => type({ bad: 5 })).toThrow(); +}); + +it("bigint", () => { + expect(() => type({ bad: 99999n })).toThrow(); +}); + +it("symbol", () => { + expect(() => type({ bad: Symbol() })).toThrow(); +}); + +it("any", () => { + type Any = typeof type.any.infer; + const T = type({ bad: {} as Any }); + const _any: Eq<typeof T.infer, { bad: Any }> = true; +}); + +it("never", () => { + const T = type({ bad: {} as never }); + const _never: Eq<typeof T.infer, { bad: never }> = true; +}); diff --git a/packages/omptype/test/ark/basis.test.ts b/packages/omptype/test/ark/basis.test.ts new file mode 100644 index 000000000..2230727eb --- /dev/null +++ b/packages/omptype/test/ark/basis.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("intersections", () => { + it("class & literal", () => { + const a = [0]; + const Literal = type("===", a); + const Cls = type("instanceof", Array); + const lr = Literal.and(Cls); + const _type: Eq<typeof lr.infer, number[]> = true; + expect(lr.json).toEqual(Literal.json); + const rl = Cls.and(Literal); + const _typeRl: Eq<typeof rl.infer, number[]> = true; + expect(rl.json).toEqual(Literal.json); + }); + + it("unsatisfiable class & literal", () => { + const a = [0]; + const Literal = type("===", a); + const Cls = type("instanceof", Date); + expect(() => Literal.and(Cls)).toThrow(); + expect(() => Cls.and(Literal)).toThrow(); + }); + + it("domain & literal", () => { + const Literal = type("'foo'"); + const Domain = type("string"); + expect(Literal.and(Domain).json).toEqual(Literal.json); + expect(Domain.and(Literal).json).toEqual(Literal.json); + }); + + it("unsatisfiable domain & literal", () => { + const Literal = type("'foo'"); + const Domain = type("number"); + expect(() => Literal.and(Domain)).toThrow(); + expect(() => Domain.and(Literal)).toThrow(); + }); + + it("domain & class", () => { + const Domain = type("object"); + const Cls = type("instanceof", Date); + expect(Domain.and(Cls).json).toEqual(Cls.json); + expect(Cls.and(Domain).json).toEqual(Cls.json); + }); +}); diff --git a/packages/omptype/test/ark/brand.test.ts b/packages/omptype/test/ark/brand.test.ts new file mode 100644 index 000000000..b946a1884 --- /dev/null +++ b/packages/omptype/test/ark/brand.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "bun:test"; +import { type Brand, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +it("fluent", () => { + const T = type("string").brand("foo"); + const _t: Eq<typeof T.t, Brand<string, "foo">> = true; + const _infer: Eq<typeof T.infer, Brand<string, "foo">> = true; + const _inferIn: Eq<typeof T.inferIn, string> = true; + + expect(T.expression).toEqual("string"); + + const out = T("moo"); + const _out: Eq<typeof out, Brand<string, "foo"> | type.errors> = true; +}); + +it("string", () => { + const T = type("number#cool"); + const _t: Eq<typeof T.t, Brand<number, "cool">> = true; + const _infer: Eq<typeof T.infer, Brand<number, "cool">> = true; + const _inferIn: Eq<typeof T.inferIn, number> = true; + + expect(T.expression).toEqual("number"); + + const out = T(5); + const _out: Eq<typeof out, Brand<number, "cool"> | type.errors> = true; +}); + +it("in object", () => { + const T = type({ + foo: "string#foo", + bar: "string.json.parse#json", + }); + + const _infer: Eq<typeof T.infer, { foo: Brand<string, "foo">; bar: Brand<Json, "json"> }> = true; + const _inferIn: Eq<typeof T.inferIn, { foo: string; bar: string }> = true; +}); + +it("in union", () => { + const T = type("string#foo | boolean"); + const _infer: Eq<typeof T.infer, boolean | Brand<string, "foo">> = true; + const _inferIn: Eq<typeof T.inferIn, boolean | string> = true; +}); + +it("from morph", () => { + const Fluent = type("string.numeric.parse").brand("num"); + const _infer: Eq<typeof Fluent.infer, Brand<number, "num">> = true; + const _inferIn: Eq<typeof Fluent.inferIn, string> = true; + + const StringType = type("string.numeric.parse#num"); + expect(StringType.json).toEqual(Fluent.json); + const _t: Eq<typeof StringType.t, typeof Fluent.t> = true; +}); + +it("docs example", () => { + const Fluent = type.number.divisibleBy(2).brand("even"); + const _t: Eq<typeof Fluent.t, Brand<number, "even">> = true; + const _inferIn: Eq<typeof Fluent.inferIn, number> = true; + const _infer: Eq<typeof Fluent.infer, Brand<number, "even">> = true; + + const StringType = type("(number % 2)#even"); + const _same: Eq<typeof StringType.t, typeof Fluent.t> = true; + expect(StringType.json).toEqual(Fluent.json); + expect(StringType(4)).toBe(4); +}); diff --git a/packages/omptype/test/ark/cast.test.ts b/packages/omptype/test/ark/cast.test.ts new file mode 100644 index 000000000..f15e575e9 --- /dev/null +++ b/packages/omptype/test/ark/cast.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +type Constructor = abstract new (...args: never[]) => unknown; + +describe("type.cast", () => { + it("primitive", () => { + const Foo = type("string" as type.cast<"foo">).t; + const _foo: Eq<typeof Foo, "foo"> = true; + }); + + it("object", () => { + const value = type({ a: "string" } as type.cast<{ a: "foo" }>).t; + const _object: Eq<typeof value, { a: "foo" }> = true; + }); + + it("primitive to object", () => { + const value = type("string" as type.cast<{ a: "foo" }>).t; + const _primitiveToObject: Eq<typeof value, { a: "foo" }> = true; + }); + + it("object to primitive", () => { + const value = type({ a: "string" } as type.cast<"foo">).t; + const _objectToPrimitive: Eq<typeof value, "foo"> = true; + }); + + it("infer function", () => { + type F = () => boolean; + const Constructable = type({} as type.cast<F>); + const _t: Eq<typeof Constructable.t, F> = true; + const _infer: Eq<typeof Constructable.infer, F> = true; + const _inferIn: Eq<typeof Constructable.in.infer, F> = true; + }); + + it("infer constructable", () => { + const Constructable = type({} as type.cast<Constructor>); + const _t: Eq<typeof Constructable.t, Constructor> = true; + const _infer: Eq<typeof Constructable.infer, Constructor> = true; + const _inferIn: Eq<typeof Constructable.in.infer, Constructor> = true; + }); + + it("undefined", () => { + const Foo = type("string" as type.cast<"foo">).t; + const _foo: Eq<typeof Foo, "foo"> = true; + }); +}); + +describe("as", () => { + it("valid cast", () => { + const From = type("/^foo.*$/"); + const T = From.as<`foo${string}`>(); + const _t: Eq<typeof T.t, `foo${string}`> = true; + expect(T === From).toBe(true); + }); + + it("cast to any", () => { + const T = type("unknown").as<any>(); + const _t: Eq<typeof T.t, any> = true; + }); + + it("cast to never", () => { + const T = type("unknown").as<never>(); + const _t: Eq<typeof T.t, never> = true; + }); + + it("missing type param", () => { + type("string").as(); + }); + + it("runtime arguments do not alter the cast", () => { + const T = type("string"); + expect(T.as("foo" as never)).toBe(T); + }); +}); + +describe("readonly ", () => { + it("object", () => { + const From = type({ foo: "string", bar: "number" }); + const T = From.readonly(); + const _t: Eq<typeof T, Type<{ readonly foo: string; readonly bar: number }>> = true; + expect(T === From).toBe(true); + }); + + it("array", () => { + const From = type("string").array(); + const T = From.readonly(); + const _t: Eq<typeof T, Type<readonly string[]>> = true; + expect(T === From).toBe(true); + }); +}); diff --git a/packages/omptype/test/ark/clone.test.ts b/packages/omptype/test/ark/clone.test.ts new file mode 100644 index 000000000..b26138eaa --- /dev/null +++ b/packages/omptype/test/ark/clone.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("preserves the original references if no morphs are present", () => { + const T = type({ foo: "string" }); + const original = { foo: "bar" }; + expect(T(original)).toBe(original); +}); + +it("clones by default before morphing", () => { + const T = type({ foo: "string.trim" }); + const original = { foo: " bar " }; + expect(T(original)).toEqual({ foo: "bar" }); + expect(original).toEqual({ foo: " bar " }); +}); + +it("default clone implementation preserves prototypes", () => { + const T = type(["Date", "=>", d => d.toISOString()]); + expect(T.from(new Date(Date.UTC(2000, 1, 1)))).toEqual("2000-02-01T00:00:00.000Z"); +}); + +it("can be configured to mutate", () => { + const types = type.module({ trimAndMutate: { foo: "string.trim" } }, { clone: false }); + const original = { foo: " bar " }; + const out = types.trimAndMutate(original); + expect(out).toEqual({ foo: "bar" }); + expect(out).toBe(original); +}); + +it("can be configured to mutate", () => { + const types = type.module({ trimAndMutate: { foo: "string.trim" } }, { clone: false }); + const original = { foo: " bar " }; + const out = types.trimAndMutate(original); + expect(out).toEqual({ foo: "bar" }); + expect(out).toBe(original); +}); + +it("can be configured to use a custom clone implementation", () => { + const types = type.module( + { trimAndMutate: { foo: "string.trim" } }, + { clone: original => ({ ...original, customCloned: true }) }, + ); + expect(types.trimAndMutate({ foo: " bar " })).toEqual({ foo: "bar", customCloned: true }); +}); + +it("can clone process.env", () => { + const Env = type({ "+": "delete", PATH: type.unit(process.env.PATH) }); + const originalEnv = { ...process.env }; + expect(Env(process.env)).toEqual({ PATH: process.env.PATH }); + expect({ ...process.env }).toEqual(originalEnv); +}); diff --git a/packages/omptype/test/ark/completions.test.ts b/packages/omptype/test/ark/completions.test.ts new file mode 100644 index 000000000..ec8fb3625 --- /dev/null +++ b/packages/omptype/test/ark/completions.test.ts @@ -0,0 +1,7 @@ +import { it } from "bun:test"; + +it.todo("completes standalone keyword"); +it.todo("completes within objects"); +it.todo("completes within expressions"); +it.todo("completes within expressions in objects"); +it.todo("completes user-defined aliases"); diff --git a/packages/omptype/test/ark/config.test.ts b/packages/omptype/test/ark/config.test.ts new file mode 100644 index 000000000..3d5fa600f --- /dev/null +++ b/packages/omptype/test/ark/config.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("tuple expression", () => { + const description = "a series of characters"; + const types = scope({ + a: ["string", "@", description], + b: { + a: "a", + }, + }).export(); + const _tupleAInference: Eq<typeof types.a.infer, string> = true; + expect(types.a.description).toEqual(description); + expect(types.a(1).toString()).toEqual("must be a series of characters (was a number)"); + const _tupleBInference: Eq<typeof types.b.infer, { a: string }> = true; + expect(types.b({ a: true }).toString()).toEqual("a must be a series of characters (was boolean)"); +}); + +it("tuple expression at path", () => { + const description = "the number of dimensions in the monster group"; + const T = type({ + monster: ["196883", "@", description], + }); + const _tuplePathInference: Eq<typeof T.infer, { monster: 196883 }> = true; + expect(T.description).toEqual("{ monster: the number of dimensions in the monster group }"); + expect(T({ monster: 196882 }).toString()).toEqual( + "monster must be the number of dimensions in the monster group (was 196882)", + ); +}); + +it("anonymous type config", () => { + const T = type(type("true", "@", { description: "unfalse" })); + const _anonymousConfigInference: Eq<typeof T.infer, true> = true; + expect(T(false).toString()).toEqual("must be unfalse (was false)"); +}); + +it("anonymous type config at path", () => { + const Unfalse = type("true", "@", { description: "unfalse" }); + const T = type({ myKey: Unfalse }); + expect(T({ myKey: "500" }).toString()).toEqual(`myKey must be unfalse (was "500")`); +}); + +it("anonymous type thunk", () => { + const T = type(() => type("false", "@", { description: "untrue" })); + const _anonymousThunkInference: Eq<typeof T.infer, false> = true; + expect(T.description).toEqual("untrue"); +}); + +it("anonymous type thunk at path", () => { + const T = type({ + myKey: () => type("false", "@", { description: "untrue" }), + }); + const _anonymousThunkPathInference: Eq<typeof T.infer, { myKey: false }> = true; + expect(T({ myKey: true }).toString()).toEqual("myKey must be untrue (was true)"); +}); + +it("shallow node writer config", () => { + const CustomOne = type("1", "@", { + expected: ctx => `custom expected ${ctx.description}`, + actual: data => `custom actual ${data}`, + problem: ctx => `custom problem ${ctx.expected} ${ctx.actual}`, + message: ctx => `custom message ${ctx.problem}`, + }); + const _shallowWriterInference: Eq<typeof CustomOne.infer, 1> = true; + expect(CustomOne(2).toString()).toEqual("custom message custom problem custom expected 1 custom actual 2"); +}); + +it("string node configs", () => { + const CustomTwo = type("2", "@", { + expected: "2", + actual: "something else", + problem: "was terrible", + message: "root was terrible", + }); + const _stringConfigInference: Eq<typeof CustomTwo.infer, 2> = true; + expect(CustomTwo(1).toString()).toEqual("root was terrible"); +}); + +it("node writer config works on nested constraint", () => { + const CustomEven = type("number % 2", "@", { + expected: ctx => `custom expected ${ctx.description}`, + actual: data => `custom actual ${data}`, + problem: ctx => `custom problem ${ctx.expected} ${ctx.actual}`, + message: ctx => `custom message ${ctx.problem}`, + }); + const _nestedWriterInference: Eq<typeof CustomEven.infer, number> = true; + expect(CustomEven(3).toString()).toEqual("custom message custom problem custom expected even custom actual 3"); +}); + +it("applies config to shallow descendants", () => { + const User = type({ + name: "string", + age: "number", + }).describe("a valid user"); + + // should give the original error at a path + expect( + User({ + name: "david", + age: true, + }).toString(), + ).toEqual("age must be a number (was boolean)"); + + // should give the shallow custom error + expect(User(null).toString()).toEqual("must be a valid user (was null)"); +}); + +it("docs actual example", () => { + // avoid logging "was supersecret" for password + const Password = type("string >= 8", "@", { actual: () => "" }); + + const User = type({ + email: "string.email", + password: Password, + }); + + const out = User({ + email: "david@arktype.io", + password: "ez123", + }); + + expect(out.toString()).toEqual("password must be at least length 8"); +}); + +it("docs message example", () => { + const User = type({ + password: "string >= 8", + }).configure({ + message: ctx => `${ctx.propString || "(root)"}: ${ctx.actual} isn't ${ctx.expected}`, + }); + // ArkErrors: (root): a string isn't an object + const out1 = User("ez123"); + expect(out1.toString()).toEqual("(root): a string isn't an object"); + // but `.configure` only applies shallowly, so the nested error isn't changed! + // ArkErrors: password must be at least length 8 (was 5) + const out2 = User({ password: "ez123" }); + expect(out2.toString()).toEqual("password must be at least length 8 (was 5)"); +}); + +describe("select", () => { + const Base = type({ + foo: "string", + "bar?": { + nested: "string", + num: "number", + }, + }); + + it("self", () => { + const T = Base.configure({ description: "root-only" }, "self"); + expect(T.description).toBe("root-only"); + expect(T("not an object").toString()).toBe("must be root-only (was a string)"); + expect(T({ foo: 5 }).toString()).toBe("foo must be a string (was a number)"); + }); + describe("completions", () => { + // based on completion tests at ark/schema/select.test.ts + it.todo("shallow completions"); + it.todo("composite key completions"); + it.todo("composite kind completions"); + it.todo("composite boundary completions"); + it.todo("composite method completions"); + }); +}); diff --git a/packages/omptype/test/ark/dateLiteral.test.ts b/packages/omptype/test/ark/dateLiteral.test.ts new file mode 100644 index 000000000..bcf6a6f6c --- /dev/null +++ b/packages/omptype/test/ark/dateLiteral.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("base", () => { + const T = type("d'2000/05/05'"); + const _type: Eq<typeof T.infer, Date> = true; + expect(T.allows(new Date("2000/05/05"))).toEqual(true); + expect(T.allows(new Date("2000/06/05"))).toEqual(false); + expect(T.allows(new Date("2000-05-05T09:00:00.000Z"))).toEqual(false); +}); + +it("with punctuation", () => { + const ISO = type("d'2000-05-05T04:00:00.000Z'"); + const _type: Eq<typeof ISO.infer, Date> = true; + expect(ISO.allows(new Date("2000-05-05T04:00:00.000Z"))).toEqual(true); + expect(ISO.allows(new Date("2000/07/05"))).toEqual(false); +}); + +it("allows spaces", () => { + const T = type("d' 2021 / 05 / 01 '"); + expect(T.allows(new Date("2021/05/01"))).toEqual(true); +}); + +it("epoch", () => { + const now = new Date(); + const T = type(`d'${now.valueOf()}'`); + expect(T.allows(now)).toEqual(true); + expect(T.allows(new Date(now.valueOf() + 1))).toEqual(false); +}); + +it("invalid date", () => { + expect(() => type("d'tuesday'")).toThrow(); +}); + +it("morphable", () => { + const T = type(["Date", "=>", d => d.toISOString()]); + const input = new Date(2000, 1); + expect(T.from(input)).toBe(input.toISOString()); +}); diff --git a/packages/omptype/test/ark/declared.test.ts b/packages/omptype/test/ark/declared.test.ts new file mode 100644 index 000000000..8d59b3b7d --- /dev/null +++ b/packages/omptype/test/ark/declared.test.ts @@ -0,0 +1,233 @@ +import { expect, it } from "bun:test"; +import { declare, type Out, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("shallow", () => { + const shallow = declare<number>().type("number"); + const _assert1: Eq<typeof shallow.infer, number> = true; + expect(shallow.json).toEqual(type("number").json); +}); + +it("obj", () => { + type Expected = { a: string; b?: number }; + const T = declare<Expected>().type({ + a: "string", + "b?": "number", + }); + const _assert2: Eq<typeof T.infer, Expected> = true; + // name should be preserved + void T.t; +}); + +it("syntax error", () => { + type Expected = { a: string; b?: number }; + expect(() => + declare<Expected>().type({ + a: "string[", + }), + ).toThrow(); +}); + +it("tuple", () => { + type Expected = [string, number]; + const T = declare<Expected>().type(["string", "number"]); + const _assert3: Eq<typeof T.infer, Expected> = true; +}); + +it("bad element", () => { + void declare<[string, number]>().type(["string", "boolean"]); +}); + +it("too short", () => { + void declare<[string, number]>().type(["string"]); +}); + +it("too long", () => { + void declare<[string, number]>().type(["string", "number", "number"]); +}); + +it("tuple expression", () => { + const T = declare<0 | 1>().type(["0", "|", "1"]); + const _assert4: Eq<typeof T.infer, 0 | 1> = true; +}); + +it("regexp", () => { + const T = declare<string>().type(/.*/); + const _assert5: Eq<typeof T.t, string> = true; + const _assert6: Eq<typeof T.infer, string> = true; +}); + +it("Inferred<t>", () => { + const Foo = type("'foo'"); + const T = declare<"foo">().type(Foo); + const _assert7: Eq<typeof T.infer, "foo"> = true; +}); + +it("bad tuple expression", () => { + void declare<"foo" | "bar">().type(["'foo'", "|", "'baz'"]); +}); + +it("narrower", () => { + void (() => declare<string>().type("'foo'")); +}); + +it("narrower in object (from docs)", () => { + type Expected = { a: string; b?: number }; + void (() => + type.declare<Expected>().type({ + a: "string", + "b?": "1", + })); +}); + +it("wider", () => { + void (() => + declare<{ a: string }>().type({ + a: "unknown", + })); +}); + +it("missing key", () => { + void (() => + declare<{ a: string; b: number }>().type({ + a: "string", + })); +}); + +it("missing optional key", () => { + void (() => + declare<{ a: string; b?: number }>().type({ + a: "string", + })); +}); + +it("empty object optional", () => { + type Expected = { f?: string }; + + void (() => type.declare<Expected>().type({})); +}); + +it("undefined as required value", () => { + type Expected = { f: string | undefined }; + + const T = declare<Expected>().type({ f: "string | undefined" }); + + const _assert8: Eq<typeof T.t, Expected> = true; +}); + +it("undefined as optional value", () => { + type Expected = { f?: string | undefined }; + + const T = declare<Expected>().type({ "f?": "string | undefined" }); + + const _assert9: Eq<typeof T.t, Expected> = true; +}); + +it("undefined as invalid optional value", () => { + type Expected = { f?: string }; + + void (() => declare<Expected>().type({ "f?": "string | undefined" })); +}); + +it("extraneous key", () => { + void (() => + declare<{ a: string }>().type({ + a: "string", + b: "boolean", + })); +}); + +it.todo("completions"); + +it.todo("nested completions"); + +it("missing generic argument", () => { + void (() => declare().type({})); +}); + +it("morph", () => { + type Expected = { a: string; b?: number }; + void (() => + declare<Expected>().type({ + a: "string.numeric.parse", + "b?": "number", + })); +}); + +it("morph in", () => { + type Expected = { a: string; b?: number }; + const T = declare<Expected, { side: "in" }>().type({ + a: "string.numeric.parse", + "b?": "number", + }); + + const _morphIn: Eq< + typeof T.t, + (In: Expected) => { + a: number; + b?: number; + } + > = true; +}); + +it("morph in mismatch", () => { + type Expected = { a: number; b?: number }; + void (() => + declare<Expected, { side: "in" }>().type({ + a: "string.numeric.parse", + "b?": "number", + })); +}); + +it("morph out", () => { + type Expected = { a: number; b?: number }; + const T = declare<Expected, { side: "out" }>().type({ + a: "string.numeric.parse", + "b?": "number", + }); + + const _morphOut: Eq<typeof T.t, (In: { a: string; b?: number }) => Out<Expected>> = true; +}); + +it("morph out mismatch", () => { + type Expected = { a: string; b?: number }; + void (() => + declare<Expected, { side: "out" }>().type({ + a: "string.numeric.parse", + "b?": "number", + })); +}); + +it("value-optional", () => { + type Expected = { f?: string }; + + const T = type.declare<Expected>().type({ + f: "string?", + }); + + const _assert10: Eq<typeof T.t, Expected> = true; +}); + +it("invalid value-optional", () => { + type Expected = { f?: string }; + + void (() => + type.declare<Expected>().type({ + f: "number?", + })); +}); + +// https://github.com/arktypeio/arktype/issues/1537 +it("github undefined issue", () => { + type Member = "a" | "b" | undefined; // without undefined it works + const memberValidator = type.declare<Member>().type(`"a" | "b" | undefined`); + + type Object = { + m: Member; + }; + const objectValidator = type.declare<Object>().type({ + m: memberValidator, + }); + + const _assert11: Eq<typeof objectValidator.t, Object> = true; +}); diff --git a/packages/omptype/test/ark/define.test.ts b/packages/omptype/test/ark/define.test.ts new file mode 100644 index 000000000..6c60d1d2e --- /dev/null +++ b/packages/omptype/test/ark/define.test.ts @@ -0,0 +1,29 @@ +import { it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("ark", () => { + const def = type.define({ + a: "string|number", + b: ["boolean"], + }); + const _assert1: Eq<typeof def, { a: "string|number"; b: readonly ["boolean"] }> = true; +}); + +it.todo("type attached"); + +it("ark error", () => { + // currently is a no-op, so only has type error + void (() => type.define({ a: "boolean|foo" })); +}); + +it("custom scope", () => { + const $ = scope({ + a: "string[]", + }); + + const ok = $.define(["a[]|boolean"]); + const _assert3: Eq<typeof ok, readonly ["a[]|boolean"]> = true; + + void (() => $.define({ not: "ok" })); +}); diff --git a/packages/omptype/test/ark/discrimination.test.ts b/packages/omptype/test/ark/discrimination.test.ts new file mode 100644 index 000000000..958458b41 --- /dev/null +++ b/packages/omptype/test/ark/discrimination.test.ts @@ -0,0 +1,284 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; + +it("2 literal branches", () => { + // should not use a switch with <=2 branches to avoid needless convolution + const T = type("'a'|'b'"); + expect(T.allows("a")).toEqual(true); + expect(T.allows("b")).toEqual(true); + expect(T.allows("c")).toEqual(false); +}); + +it(">2 literal branches", () => { + const T = type("'a'|'b'|'c'"); + expect(T.allows("a")).toEqual(true); + expect(T.allows("b")).toEqual(true); + expect(T.allows("c")).toEqual(true); + expect(T.allows("d")).toEqual(false); +}); + +it(">2 domain branches", () => { + const T = type("string|bigint|number"); + expect(T.allows("foo")).toEqual(true); + expect(T.allows(5n)).toEqual(true); + expect(T.allows(5)).toEqual(true); + expect(T.allows(true)).toEqual(false); +}); + +it("literals can be included in domain branches", () => { + const T = type("string|bigint|true"); + expect(T.allows("foo")).toEqual(true); + expect(T.allows(5n)).toEqual(true); + expect(T.allows(true)).toEqual(true); + expect(T.allows(5)).toEqual(false); +}); + +const getPlaces = () => + scope({ + rainForest: { + climate: "'wet'", + color: "'green'", + isRainForest: "true", + }, + desert: { climate: "'dry'", color: "'brown'", isDesert: "true" }, + sky: { climate: "'dry'", color: "'blue'", isSky: "true" }, + ocean: { climate: "'wet'", color: "'blue'", isOcean: "true" }, + }); + +it("nested", () => { + const $ = getPlaces(); + const climate = $.type("ocean | sky | rainForest | desert"); + + const missingLabel = climate({ + climate: "wet", + color: "blue", + }); + + expect(missingLabel.toString()).toBe("isOcean must be true (was missing)"); + + const twoMissingKeys = climate({ + color: "blue", + }); + + expect(twoMissingKeys.toString()).toBe('climate must be "wet" or "dry" (was undefined)'); +}); + +it("indiscriminable", () => { + const T = getPlaces().type([ + "ocean", + "|", + { + climate: "'wet'", + color: "'blue'", + indistinguishableFrom: "ocean", + }, + ]); + + expect(T.allows({ climate: "wet", color: "blue", isOcean: true })).toBe(true); + expect( + T.allows({ + climate: "wet", + color: "blue", + indistinguishableFrom: { climate: "wet", color: "blue", isOcean: true }, + }), + ).toBe(true); + expect(T.allows({ climate: "wet", color: "green" })).toBe(false); +}); + +it("discriminate optional key", () => { + const T = type({ + direction: "'forward' | 'backward'", + "operator?": "'by'", + }).or({ + duration: "'s' | 'min' | 'h'", + operator: "'to'", + }); + + expect(T.allows({ direction: "forward" })).toBe(true); + expect(T.allows({ direction: "backward", operator: "by" })).toBe(true); + expect(T.allows({ duration: "min", operator: "to" })).toBe(true); + expect(T.allows({ duration: "min", operator: "by" })).toBe(false); +}); + +it("overlapping default case", () => { + const T = getPlaces().type(["ocean|rainForest", "|", { temperature: "'hot'" }]); + + expect(T.allows({ climate: "wet", color: "blue", isOcean: true })).toBe(true); + expect(T.allows({ climate: "wet", color: "green", isRainForest: true })).toBe(true); + expect(T.allows({ temperature: "hot" })).toBe(true); + expect(T.allows({ temperature: "cold" })).toBe(false); +}); + +it("discriminable default", () => { + const T = getPlaces().type([{ temperature: "'cold'" }, "|", ["ocean|rainForest", "|", { temperature: "'hot'" }]]); + + expect(T.allows({ temperature: "cold" })).toBe(true); + expect(T.allows({ temperature: "hot" })).toBe(true); + expect(T.allows({ climate: "wet", color: "blue", isOcean: true })).toBe(true); + expect(T.allows({ temperature: "warm" })).toBe(false); +}); + +it("won't discriminate between possibly empty arrays", () => { + const T = type("string[]|boolean[]"); + expect(T.allows([])).toBe(true); + expect(T.allows(["value"])).toBe(true); + expect(T.allows([false])).toBe(true); + expect(T.allows([1])).toBe(false); +}); + +it("discriminant path including symbol", () => { + const s = Symbol("lobmyS"); + const T = type({ [s]: "0" }).or({ [s]: "1" }); + expect(T.allows({ [s]: 0 })).toEqual(true); + expect(T.allows({ [s]: -1 })).toEqual(false); + + expect(T({ [s]: 1 })).toEqual({ [s]: 1 }); + expect(T({ [s]: 2 }).toString()).toBe("[Symbol(lobmyS)] must be 0 or 1 (was 2)"); +}); + +// https://github.com/arktypeio/arktype/issues/1100 +it("discriminated null + object", () => { + const Company = type({ + id: "number", + }).or("string | null"); + + expect(Company(null)).toEqual(null); + expect(Company({ id: 1 })).toEqual({ id: 1 }); + expect(Company("foo")).toEqual("foo"); + expect(String(Company(5))).toBe("must be { id: a number }, a string or null (was a number)"); +}); + +it("differing inner discriminated paths", () => { + const Discriminated = type( + { + innerA: { + id: "1", + }, + }, + "|", + { + innerB: { + id: "1", + }, + }, + ) + .or({ innerA: { id: "2" } }) + .or({ innerB: { id: "2" } }); + + expect(Discriminated({ innerA: { id: 1 } })).toEqual({ innerA: { id: 1 } }); + expect(Discriminated({ innerB: { id: 1 } })).toEqual({ innerB: { id: 1 } }); + expect(Discriminated({ innerA: { id: 2 } })).toEqual({ innerA: { id: 2 } }); + expect(Discriminated({ innerB: { id: 2 } })).toEqual({ innerB: { id: 2 } }); + + expect(Discriminated({})?.toString()).toBe("innerB.id must be 1 or 2 (was undefined)"); +}); + +it("allows strict discriminated keys", () => { + const AorB = type({ + type: "'A'", + }) + .or({ + type: "'B'", + }) + .onUndeclaredKey("reject"); + + expect(AorB({ type: "A" })).toEqual({ type: "A" }); + expect(AorB.allows({ type: "B" })).toBe(true); + expect(AorB.allows({ type: "A", extra: true })).toBe(false); +}); + +it("can discriminated objects with disjoint strict keys", () => { + const AorB = type({ + "+": "reject", + something: "'A'", + }).or({ + "+": "reject", + something: "'B'", + somethingelse: "number", + }); + + expect(AorB({ something: "A" })).toEqual({ something: "A" }); + expect(AorB({ something: "B", somethingelse: 1 })).toEqual({ something: "B", somethingelse: 1 }); + expect(AorB.allows({ something: "B" })).toBe(false); +}); + +it("includes non-disjoint branches in corresponding cases", () => { + const T = type({ + id: "0", + k1: "number", + }) + .or({ id: "1", k1: "number" }) + .or({ + name: "string", + }); + + // should hit the case discriminated for id: 1, + // but still resolve correctly via the { name: string } branch + expect(T({ name: "foo", id: 1 })).toEqual({ name: "foo", id: 1 }); +}); + +it("correctly dsicriminated onDeclaredKey: reject in the above scenario", () => { + const T = type({ + id: "0", + k1: "number", + }) + .or({ id: "1", k1: "number" }) + .or({ + "+": "reject", + name: "string", + }); + + // now that we are rejecting undeclared keys, all branches fail + expect(T({ name: "foo", id: 1 }).toString()).toBe("k1 must be a number (was missing)"); +}); + +it("discriminate array and tuple", () => { + const T = type("null[] | false").or([type.undefined]); + + expect(T.allows(false)).toBe(true); + expect(T.allows([null, null])).toBe(true); + expect(T.allows([undefined])).toBe(true); + expect(T.allows(true)).toBe(false); + expect(T.allows([undefined, undefined])).toBe(false); +}); + +it("discriminate bounded array and tuple", () => { + const T = type("3 <= null[] <= 10 | false").or([type.undefined]); + + expect(T.allows(false)).toBe(true); + expect(T.allows([null, null, null])).toBe(true); + expect(T.allows(new Array(10).fill(null))).toBe(true); + expect(T.allows([undefined])).toBe(true); + expect(T.allows([null, null])).toBe(false); + expect(T.allows(new Array(11).fill(null))).toBe(false); +}); + +it("dimscrinate literal undefined value", () => { + const T = type(["number[]", "|", ["undefined"]]); + + expect(T.assert([])).toEqual([]); +}); + +// https://github.com/arktypeio/arktype/issues/1547 +it("discriminates cyclic union on nested path", () => { + const s = scope({ + AChild: { type: "'AChild'", children: "(AParent)[] > 0" }, + AParent: { type: "'AParent'", children: "(AChild)[] > 0" }, + BChild: { type: "'BChild'", children: "unknown[]" }, + BParent: { + type: "'BParent'", + layout: "number[]", + children: "(BChild)[] > 0", + }, + }); + + const Thing = s.type("AParent | BParent"); + + expect( + Thing({ + type: "BParent", + layout: "", + children: [{ type: "BChild", children: [] }], + }).toString(), + ).toBe("layout must be an array (was a string)"); +}); diff --git a/packages/omptype/test/ark/divisor.test.ts b/packages/omptype/test/ark/divisor.test.ts new file mode 100644 index 000000000..ef59cf5a2 --- /dev/null +++ b/packages/omptype/test/ark/divisor.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("parse", () => { + it("integer literal", () => { + const DivisibleByTwo = type("number%2"); + const _type: Eq<typeof DivisibleByTwo.infer, number> = true; + }); + + it("chained", () => { + const T = type("number").divisibleBy(2); + const Expected = type("number%2"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("whitespace after %", () => { + const T = type("number % 5"); + const _type: Eq<typeof T.infer, number> = true; + }); + + it("with bounds", () => { + const T = type("7<number%8<222"); + const Expected = type("number%8").and("7<number<222"); + expect(T.json).toEqual(Expected.json); + expect(T.description).toBe("a number more than 7 and less than 222 divisible by 8"); + }); + + it("docs example", () => { + const N = type("0 < number <= 100"); + + expect(N.description).toBe("a number more than 0 and at most 100"); + }); + + it("allows non-narrowed divisor", () => { + const d = 5 as number; + const T = type(`number%${d}`); + const _type: Eq<typeof T.infer, number> = true; + }); + + it("fails at runtime on non-integer divisor", () => { + expect(() => type("number%2.3")).toThrow(); + }); + + it("non-numeric divisor", () => { + expect(() => type("number%foobar")).toThrow(); + }); + + it("zero divisor", () => { + expect(() => type("number%0")).toThrow(); + }); + + it("unknown", () => { + expect(() => type("unknown%2")).toThrow(); + }); + + it("indivisible", () => { + expect(() => type("string%1")).toThrow(); + }); + + it("morph", () => { + expect(() => type("string.numeric.parse > 2")).toThrow('cannot bound morph in "string.numeric.parse > 2"'); + }); + + it("chained indivisible", () => { + expect(() => type("string").divisibleBy(2)).toThrow(); + }); + + it("overlapping", () => { + expect(() => type("(number|string)%10")).toThrow(); + }); +}); + +describe("intersection", () => { + it("identical", () => { + const T = type("number%2&number%2"); + expect(T(2)).toBe(2); + }); + + it("purely divisible", () => { + const T = type("number%4&number%2"); + expect(T(4)).toBe(4); + }); + + it("common divisor", () => { + const T = type("number%6&number%4"); + expect(T(12)).toBe(12); + }); + + it("relatively prime", () => { + const T = type("number%2&number%3"); + expect(T(6)).toBe(6); + }); + + it("valid literal", () => { + const T = type("number%5&0"); + expect(T(0)).toBe(0); + }); + it("invalid literal", () => { + expect(() => type("number%3&8")).toThrow("literal is excluded by intersection"); + }); +}); diff --git a/packages/omptype/test/ark/enclosed.test.ts b/packages/omptype/test/ark/enclosed.test.ts new file mode 100644 index 000000000..6b1081038 --- /dev/null +++ b/packages/omptype/test/ark/enclosed.test.ts @@ -0,0 +1,155 @@ +import { expect, it } from "bun:test"; +import { type Out, type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +type RegexExecArray<captures extends readonly string[], groups extends object, input extends string> = captures & { + groups: groups; + index: number; + input: input; +}; + +it("with spaces", () => { + const T = type("'this has spaces'"); + const _infer: Eq<typeof T.infer, "this has spaces"> = true; + expect(_infer).toBe(true); +}); + +it("with neighbors", () => { + const T = type("'foo'|/.*/[]"); + const _infer: Eq<typeof T.infer, "foo" | string[]> = true; + expect(_infer).toBe(true); +}); + +it("unterminated regex", () => { + expect(() => type("/.*")).toThrow(); +}); + +it("unterminated single-quote", () => { + expect(() => type("'.*")).toThrow(); +}); + +it("unterminated double-quote", () => { + expect(() => type('".*')).toThrow(); +}); + +it("single-quoted", () => { + const T = type("'hello'"); + const _infer: Eq<typeof T.infer, "hello"> = true; + expect(_infer).toBe(true); +}); + +it("double-quoted", () => { + const T = type('"goodbye"'); + const _infer: Eq<typeof T.infer, "goodbye"> = true; + expect(_infer).toBe(true); +}); + +it("regex literal", () => { + const T = type("/.*/"); + const _infer: Eq<typeof T.infer, string> = true; + expect(_infer).toBe(true); +}); + +it("invalid regex", () => { + expect(() => type("/[/")).toThrow('invalid regular expression "/[/"'); +}); + +it("regex exec literal", () => { + const T = type("x/^a(b)c$/"); + // biome-ignore lint/complexity/noBannedTypes: RegexExecArray type parameter + const _type: Eq<typeof T, Type<(In: "abc") => Out<RegexExecArray<["abc", "b"], {}, "">>>> = true; + expect(_type).toBe(true); + expect(T("abc")).toEqual(["abc", "b"]); +}); + +it("invalid regex exec literal", () => { + expect(() => type("x/[/")).toThrow('invalid regular expression "/[/"'); +}); + +it("nested regex exec literal", () => { + const User = type({ + birthday: "x/^(?<month>\\d{2})-(?<day>\\d{2})-(?<year>\\d{4})$/", + }); + + const _type: Eq< + typeof User, + Type<{ + birthday: ( + In: `${number}-${number}-${number}`, + ) => Out< + RegexExecArray< + [`${number}-${number}-${number}`, `${number}`, `${number}`, `${number}`], + { month: `${number}`; day: `${number}`; year: `${number}` }, + "" + > + >; + }> + > = true; + expect(_type).toBe(true); + + const data = User.assert({ birthday: "05-21-1993" }); + expect(data.birthday.groups).toEqual({ month: "05", day: "21", year: "1993" }); +}); + +it("mixed quote types", () => { + const T = type(`"'single-quoted'"`); + const _tInfer: Eq<typeof T.infer, "'single-quoted'"> = true; + expect(_tInfer).toBe(true); + + const U = type(`'"double-quoted"'`); + const _uInfer: Eq<typeof U.infer, '"double-quoted"'> = true; + expect(_uInfer).toBe(true); +}); + +it("ignores enclosed operators", () => { + const T = type("'yes|no|maybe'"); + const _infer: Eq<typeof T.infer, "yes|no|maybe"> = true; + expect(_infer).toBe(true); +}); + +it("mix of enclosed and unenclosed operators", () => { + const T = type("'yes|no'|'true|false'"); + const _infer: Eq<typeof T.infer, "yes|no" | "true|false"> = true; + expect(_infer).toBe(true); +}); + +it("escaped enclosing", () => { + const T = type("'don\\'t'"); + const _infer: Eq<typeof T.infer, "don't"> = true; + expect(_infer).toBe(true); +}); + +it("escaped backslash", () => { + const T = type("'\\\\'"); + const Expected = type.unit("\\"); + const _infer: Eq<typeof T.t, typeof Expected.t> = true; + expect(_infer).toBe(true); +}); + +it("string literal stress", () => { + const s = `"3. +14159265358979323846264338327950288419716939937510 +58209749445923078164062862089986280348253421170679 +82148086513282306647093844609550582231725359408128 +48111745028410270193852110555964462294895493038196 +44288109756659334461284756482337867831652712019091 +45648566923460348610454326648213393607260249141273 +72458700660631558817488152092096282925409171536436 +78925903600113305305488204665213841469519415116094 +33057270365759591953092186117381932611793105118548 +07446237996274956735188575272489122793818301194912 +98336733624406566430860213949463952247371907021798 +60943702770539217176293176752384674818467669405132 +00056812714526356082778577134275778960917363717872 +14684409012249534301465495853710507922796892589235 +42019956112129021960864034418159813629774771309960 +51870721134999999837297804995105973173281609631859 +50244594553469083026425223082533446850352619311881 +71010003137838752886587533208381420617177669147303 +59825349042875546873115956286388235378759375195778 +185778053217122680661300192"`; + const T = type(s); + type Expected = typeof s extends `"${infer enclosed}"` ? enclosed : never; + const _infer: Eq<typeof T.infer, Expected> = true; + expect(_infer).toBe(true); +}); diff --git a/packages/omptype/test/ark/expressions.test.ts b/packages/omptype/test/ark/expressions.test.ts new file mode 100644 index 000000000..3bc4b95fe --- /dev/null +++ b/packages/omptype/test/ark/expressions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("tuple expressions", () => { + it("nested", () => { + const T = type(["string|bigint", "|", ["number", "|", "boolean"]]); + const _1: Eq<typeof T.infer, string | number | bigint | boolean> = true; + }); + + it.todo("autocompletion"); + + it("missing right operand", () => { + expect(() => type(["string", "|"])).toThrow(); + expect(() => type(["string", "&"])).toThrow(); + expect(() => type(["string", "|>"])).toThrow(); + }); + + it("nested parse error", () => { + expect(() => { + type(["string", "|", "numbr"]); + }).toThrow(); + }); + + it("nested object parse error", () => { + expect(() => { + type([{ s: "strng" }, "|", "number"]); + }).toThrow(); + }); +}); + +describe("root expression", () => { + it("=== single", () => { + const T = type("===", 5); + const _2: Eq<typeof T.infer, 5> = true; + expect(T.json).toEqual(type("5").json); + }); + + it("=== branches", () => { + const T = type("===", "foo", "bar", "baz"); + const _3: Eq<typeof T.infer, "foo" | "bar" | "baz"> = true; + expect(T.json).toEqual([{ unit: "foo" }, { unit: "bar" }, { unit: "baz" }]); + }); + + it("instanceof single", () => { + const T = type("instanceof", RegExp); + const _4: Eq<typeof T.infer, RegExp> = true; + const value = /omptype/; + expect(T(value)).toBe(value); + expect(T.allows({})).toBe(false); + }); + + it("instanceof branches", () => { + const T = type("instanceof", Array, Date); + const _5: Eq<typeof T.infer, unknown[] | Date> = true; + const date = new Date(); + const array: unknown[] = []; + expect(T(date)).toBe(date); + expect(T(array)).toBe(array); + expect(T.allows(/not-a-branch/)).toBe(false); + }); + + it("postfix", () => { + const T = type({ a: "string" }, "[]"); + const _6: Eq<typeof T.infer, { a: string }[]> = true; + expect(T.json).toEqual(type({ a: "string" }).array().json); + }); + + it("infix", () => { + const T = type({ a: "string" }, "|", { b: "boolean" }); + const _7: Eq< + typeof T.infer, + | { + a: string; + } + | { + b: boolean; + } + > = true; + + expect(T.json).toEqual(type({ a: "string" }).or({ b: "boolean" }).json); + }); + + it("morph", () => { + const T = type({ a: "string" }, "=>", In => ({ b: In.a })); + + expect(T.expression).toEqual("(In: { a: string }) => Out<unknown>"); + }); + + it("narrow", () => { + const T = type({ a: "string" }, ":", (In): In is { a: "foo" } => In.a === "foo"); + const _8: Eq<typeof T.infer, { a: "foo" }> = true; + }); + + it("tuple as second arg", () => { + // this case is not fundamentally unique but TS has a hard time + // narrowing tuples in contexts like this + const T = type("keyof", [{ a: "string" }, "&", { b: "boolean" }]); + const Expected = type("'a' | 'b'"); + const _9: Eq<typeof T.infer, typeof Expected.infer> = true; + expect(T.json).toEqual(Expected.json); + }); +}); diff --git a/packages/omptype/test/ark/filter.test.ts b/packages/omptype/test/ark/filter.test.ts new file mode 100644 index 000000000..d3c1e02c0 --- /dev/null +++ b/packages/omptype/test/ark/filter.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +const parseNumber = (s: string) => Number(s); + +it("applies to input", () => { + const stringIsLong = (s: string) => s.length > 5; + const ParseLongNumber = type("string").pipe(parseNumber).filter(stringIsLong); + + const _t: Eq<typeof ParseLongNumber.t, (In: string) => number> = true; + + expect(ParseLongNumber("123456")).toEqual(123456); + expect(String(ParseLongNumber("123"))).toBe('must be valid according to stringIsLong (was "123")'); + expect(String(ParseLongNumber(123456))).toBe("must be a string (was a number)"); +}); + +it("predicate inferred on input", () => { + const stringIsIntegerLike = (s: string): s is `${bigint}` => /^-?\d+$/.test(s); + const ParseIntegerLike = type("string").pipe(parseNumber).filter(stringIsIntegerLike); + + const _t: Eq<typeof ParseIntegerLike.t, (In: `${bigint}`) => number> = true; + + expect(ParseIntegerLike("123456")).toEqual(123456); + expect(String(ParseIntegerLike("3.14159"))).toBe('must be valid according to stringIsIntegerLike (was "3.14159")'); + expect(String(ParseIntegerLike(123456))).toBe("must be a string (was a number)"); +}); diff --git a/packages/omptype/test/ark/fn.test.ts b/packages/omptype/test/ark/fn.test.ts new file mode 100644 index 000000000..6d8f107d3 --- /dev/null +++ b/packages/omptype/test/ark/fn.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "bun:test"; + +const badFnReturnTypeMessage = + '":" must be followed by exactly one return type e.g:\nfn("string", ":", "number")(s => s.length)'; +const multipleVariadicMesage = "A tuple may have at most one variadic element"; +const optionalOrDefaultableAfterVariadicMessage = "An optional element may not follow a variadic element"; +const postfixAfterOptionalOrDefaultableMessage = + "A postfix required element cannot follow an optional or defaultable element"; + +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("0 params implicit return", () => { + const f = type.fn()(() => 5); + + expect(f()).toEqual(5); + + expect(f.expression).toBe("() => unknown"); +}); + +it("0 params explicit return", () => { + const f = type.fn(":", "5")(() => 5); + + expect(f()).toEqual(5); + + expect(f.expression).toBe("() => 5"); +}); + +it("1 param implicit return", () => { + const len = type.fn("string | number[]")(s => s.length); + + expect(len.expression).toBe("(string | number[]) => unknown"); + + expect(len("foo")).toEqual(3); + + // @ts-expect-error + expect(() => len(1)).toThrow("[0] must be a string or an array (was a number)"); +}); + +it("1 param explicit return", () => { + const len = type.fn("string | unknown[]", ":", "number")(s => s.length); + + expect(len.expression).toBe("(string | Array) => number"); + expect(len("foo")).toEqual(3); + + // @ts-expect-error + expect(() => len(1)).toThrow("[0] must be a string or an array (was a number)"); +}); + +it("2 params implicit return", () => { + const isNumericEquivalent = type.fn("string", "number")((s, n) => s === `${n}`); + + expect(isNumericEquivalent.expression).toBe("(string, number) => unknown"); + expect(isNumericEquivalent("5", 5)).toEqual(true); +}); + +it("2 params explicit return", () => { + const isNumericEquivalent = type.fn("string", "number", ":", "boolean")((s, n) => s === `${n}`); + + expect(isNumericEquivalent.expression).toBe("(string, number) => boolean"); + expect(isNumericEquivalent("5", 5)).toEqual(true); +}); + +it("morphs", () => { + const stringToLength = type.string.pipe(function _fnStringToLength(s) { + return s.length; + }, type.number); + + const f = type.fn(stringToLength, ":", stringToLength)(n => n.toFixed(2)); + expect(f.expression).toBe("((In: string) => To<number>) => (In: string) => To<number>"); +}); + +it("nary inferred return", () => { + const f = type.fn( + { a: "1" }, + { b: "2" }, + { c: "3" }, + { d: "4" }, + { e: "5" }, + { f: "6" }, + { g: "7" }, + { h: "8" }, + { i: "9" }, + { j: "10" }, + { k: "11" }, + { l: "12" }, + { m: "13" }, + { n: "14" }, + { o: "15" }, + { p: "16" }, + { q: "17" }, + )((a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q) => ({ + ...a, + ...b, + ...c, + ...d, + ...e, + ...f, + ...g, + ...h, + ...i, + ...j, + ...k, + ...l, + ...m, + ...n, + ...o, + ...p, + ...q, + })); + + expect(f.expression).toBe( + "({ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 }, { f: 6 }, { g: 7 }, { h: 8 }, { i: 9 }, { j: 10 }, { k: 11 }, { l: 12 }, { m: 13 }, { n: 14 }, { o: 15 }, { p: 16 }, { q: 17 }) => unknown", + ); +}); + +it("nary declared return", () => { + const f = type.fn( + { a: "1" }, + { b: "2" }, + { c: "3" }, + { d: "4" }, + { e: "5" }, + { f: "6" }, + { g: "7" }, + { h: "8" }, + { i: "9" }, + { j: "10" }, + { k: "11" }, + { l: "12" }, + { m: "13" }, + { n: "14" }, + { o: "15" }, + ":", + { p: "16" }, + )((a, b, c, d, e, f, g, h, i, j, k, l, m, n, o) => ({ + ...a, + ...b, + ...c, + ...d, + ...e, + ...f, + ...g, + ...h, + ...i, + ...j, + ...k, + ...l, + ...m, + ...n, + ...o, + p: 16, + })); + + expect(f.expression).toBe( + "({ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 }, { f: 6 }, { g: 7 }, { h: 8 }, { i: 9 }, { j: 10 }, { k: 11 }, { l: 12 }, { m: 13 }, { n: 14 }, { o: 15 }) => { p: 16 }", + ); +}); + +it.todo("signature precedence implicit return"); + +it.todo("signature precedence explicit return"); + +it("attached params", () => { + const len = type.fn("string | unknown[]")(s => s.length); + + const expectedParams = type(["string | unknown[]"]); + + const _params: Eq<typeof len.params, typeof expectedParams> = true; + expect(len.params.expression).toEqual(expectedParams.expression); +}); + +it("inferred returns", () => { + const len = type.fn("string | unknown[]")(s => s.length); + + const Expected = type.unknown; + const _inferredReturns: Eq<typeof len.returns, typeof Expected> = true; + expect(len.returns.expression).toEqual(Expected.expression); +}); + +it("introspectable returns", () => { + const len = type.fn("string | unknown[]", ":", "number")(s => s.length); + + const Expected = type.number; + const _introspectableReturns: Eq<typeof len.returns, typeof Expected> = true; + expect(len.returns.expression).toEqual(Expected.expression); +}); + +it("missing return", () => { + // the type message just ends up being some overload nonsense + // but hopefully people will not try to do this and get confused + // @ts-expect-error + expect(() => type.fn("string", ":")).toThrow(badFnReturnTypeMessage); +}); + +it("name", () => { + const f = type.fn("string")(function originalName() {}); + expect(f.name).toBe("bound typed originalName"); +}); + +it("raw", () => { + // raw has no type-level inference, so it's returned as an untyped parser + const len = type.fn.raw("string | unknown[]")((s: string) => s.length) as (data: unknown) => number; + + expect(len("foo")).toEqual(3); + expect(() => len(1)).toThrow("[0] must be a string or an array (was a number)"); +}); + +it.todo("arg submodule completions"); + +it.todo("arg object completions"); + +it.todo("returns submodule completions"); + +it.todo("returns object completions"); + +describe("scoped", () => { + it("scoped param and return", () => { + const $ = type.scope({ + xxx: "string", + zzz: "number", + }); + + const f = $.type.fn("xxx", ":", "zzz")(s => s.length); + + expect(f("foo")).toEqual(3); + + expect(f.expression).toBe("(string) => number"); + + // @ts-expect-error + expect(() => f(null)).toThrow("[0] must be a string (was null)"); + }); + + it.todo("completions"); +}); + +describe("tuple elements", () => { + it("defaultable and optional", () => { + const f = type.fn("string", "number = 5", "boolean?")((s, n, b) => `${s}${n}${b}`); + + expect(f.expression).toBe("(string, number = 5, boolean?) => unknown"); + }); + + it("non-variadic array", () => { + const join = type.fn("string[]")((...parts) => parts.join(",")); + + expect(join.expression).toBe("(string[]) => unknown"); + }); + + it("variadic array", () => { + const join = type.fn("...", "string[]", ":", "string")((...parts) => parts.join(",")); + + expect(join.expression).toBe("(...string[]) => string"); + }); + + it("intro example", () => { + const safe = type.fn("string", "number = 0.1")((name, version) => `${name}@${version} is safe AF.`); + + expect(safe("arktype", 2.2)).toBe("arktype@2.2 is safe AF."); + expect(() => safe("shitescript", "*" as unknown as number)).toThrow("[1] must be a number (was a string)"); + }); + + describe("errors", () => { + it("errors on multiple variadic", () => { + expect(() => + // @ts-expect-error + type.fn("...", "string[]", "...", "number[]")(() => {}), + ).toThrow(multipleVariadicMesage); + }); + + it("error on optional post-variadic in spread", () => { + // no type error yet, ideally would have one if tuple + // parsing were more precise for nested spread tuples + expect(() => type.fn("...", "string[]", "...", ["string?"])(() => {})).toThrow( + optionalOrDefaultableAfterVariadicMessage, + ); + }); + + it("errors on postfix following optional", () => { + expect(() => + // @ts-expect-error + type.fn("number?", "...", "boolean[]", "symbol")(() => {}), + ).toThrow(postfixAfterOptionalOrDefaultableMessage); + }); + + it("errors on postfix following defaultable", () => { + expect(() => + // @ts-expect-error + type.fn("number = 0", "...", "boolean[]", "symbol")(() => {}), + ).toThrow(postfixAfterOptionalOrDefaultableMessage); + }); + }); +}); diff --git a/packages/omptype/test/ark/generic.test.ts b/packages/omptype/test/ark/generic.test.ts new file mode 100644 index 000000000..75b4e6889 --- /dev/null +++ b/packages/omptype/test/ark/generic.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("standalone", () => { + it("unary", () => { + const boxOf = type("<t>", { box: "t" }); + + const SchrodingersBox = boxOf({ cat: { isAlive: "boolean" } }); + + const Expected = type({ + box: { + cat: { isAlive: "boolean" }, + }, + }); + const _attestActual78 = SchrodingersBox.t; + const _attestType78: Eq<typeof _attestActual78, typeof Expected.t> = true; + + expect(SchrodingersBox.json).toEqual(Expected.json); + }); + + it.todo("body completions"); + + it.todo("args completions"); + + it("binary", () => { + const either = type("<first, second>", "first|second"); + const SchrodingersBox = either({ cat: { isAlive: "true" } }, { cat: { isAlive: "false" } }); + + const Expected = type( + { + cat: { + isAlive: "true", + }, + }, + "|", + { + cat: { + isAlive: "false", + }, + }, + ); + const _attestActual74 = SchrodingersBox.t; + const _attestType74: Eq<typeof _attestActual74, typeof Expected.t> = true; + + // ideally, this would be reduced to { cat: { isAlive: boolean } }: + // https://github.com/arktypeio/arktype/issues/751 + expect(SchrodingersBox.json).toEqual(Expected.json); + }); + + it("referenced from other scope", () => { + const types = scope({ + arrayOf: type("<t>", "t[]"), + }).export(); + + const StringArray = types.arrayOf("string"); + const Expected = type("string[]"); + const _attestActual72 = StringArray.t; + const _attestType72: Eq<typeof _attestActual72, typeof Expected.t> = true; + + expect(StringArray.json).toEqual(Expected.json); + }); + + it("this not resolvable in generic def", () => { + expect(() => + // @ts-expect-error + type("<t>", { + box: "t | this", + }), + ).toThrow(); + }); + + it("this in arg", () => { + const boxOf = type("<t>", { + box: "t", + }); + + const T = boxOf({ + a: "string | this", + }); + + expect(String(T.expression)).toMatch(/{ box: { a: string \| .* } }/); + expect(T.allows({ box: { a: { a: "leaf" } } })).toBe(true); + expect(T.allows({ box: { a: 1 } })).toBe(false); + }); + + it("rejects too few args", () => { + const pair = type("<a, b>", ["a", "b"]); + expect(() => pair("string")).toThrow(); + }); + + it("rejects too many args", () => { + const pair = type("<a, b>", ["a", "b"]); + expect(() => pair("string", "number", "boolean")).toThrow(); + }); +}); + +describe("constraints", () => { + const testNonEmpty = (nonEmpty: (definition: unknown) => { readonly expression: string }) => { + const T = nonEmpty("number[]"); + const Expected = type("number[] > 0"); + + expect(T.expression).toEqual(Expected.expression); + }; + + it("can apply constraints to parameters", () => { + const nonEmpty = type("<arr extends unknown[]>", "arr > 0"); + testNonEmpty(nonEmpty); + }); + + it("can apply constraints with whitespace", () => { + const nonEmpty = type("< arr extends unknown []>", "arr > 0"); + testNonEmpty(nonEmpty); + }); + + it("constrained constraint", () => { + const positiveToInteger = type("<n extends number > 0>", "n % 1"); + + const T = positiveToInteger("number > 0"); + const Expected = type("number.integer > 0"); + const _attestActual63 = T.t; + const _attestType63: Eq<typeof _attestActual63, typeof Expected.t> = true; + + expect(T.expression).toEqual(Expected.expression); + + expect(() => positiveToInteger("number")).toThrow(); + }); + + it("unsatisfied parameter string", () => { + const $ = scope({ + "entry<k extends Key, v>": ["k", "v"], + foobar: "entry<'foo', 'bar'>", + }); + + const types = $.export(); + + const Expected = type(["'foo'", "'bar'"]); + const _attestActual60 = types.foobar.t; + const _attestType60: Eq<typeof _attestActual60, typeof Expected.t> = true; + + expect(types.foobar.expression).toEqual(Expected.expression); + + // @ts-expect-error + expect(() => $.type("entry<0, 1>")).toThrow(); + }); + + it("can parse constraint including alias from current scope", () => { + const $ = scope({ + "entry<k extends key, v>": ["k", "v"], + key: "string | symbol", + }); + + const types = $.export(); + + const Ok = types.entry("string", "number"); + const _attestActual57 = Ok.t; + const _attestType57: Eq<typeof _attestActual57, [string, number]> = true; + + expect(Ok.expression).toEqual("[string, number]"); + + // @ts-expect-error + expect(() => types.entry("boolean", "number")).toThrow(); + }); + + it("errors on unsatisfied constraints from current scope", () => { + expect(() => + scope({ + "entry<k extends specialKey, v>": ["k", "v"], + specialKey: "string | symbol", + goodEntry: "entry<'foo', 1>", + // @ts-expect-error + badEntry: "entry<1, 0>", + }).export(), + ).toThrow(); + }); + + it("constraint parse error", () => { + expect(() => { + // @ts-expect-error + type("<n extends nummer>", "n > 0"); + }).toThrow(); + }); + + it("constraint semantic parse error", () => { + expect(() => { + // @ts-expect-error + type("<boo extends boolean > 0>", "boo"); + }).toThrow(); + }); + + it("default constraint is unknown", () => { + // @ts-expect-error + expect(() => type("<arr>", "arr > 0")).toThrow(); + }); +}); + +describe("scoped", () => { + const _setup = () => { + const $ = scope({ + "box<t,u>": { + box: "t|u", + }, + bitBox: "box<0,1>", + }); + + return { $, types: $.export() }; + }; + it("referenced in scope", () => { + const { types } = _setup(); + const Expected = type({ box: "0|1" }); + expect(types.bitBox.json).toEqual(Expected.json); + + const _attestActual49 = types.bitBox.t; + const _attestType49: Eq<typeof _attestActual49, typeof Expected.t> = true; + }); + + it("nested", () => { + const { $ } = _setup(); + const T = $.type("box<0|1, box<'one', 'zero'>>"); + + const Expected = type({ box: ["0|1", "|", { box: "'one'|'zero'" }] }); + const _attestActual48 = T.t; + const _attestType48: Eq<typeof _attestActual48, typeof Expected.t> = true; + + expect(T.json).toEqual(Expected.json); + }); + + it("in expression", () => { + const { $ } = _setup(); + const T = $.type("string | box<0, 1> | boolean"); + + const Expected = type("string", "|", { box: "0|1" }).or("boolean"); + const _attestActual46 = T.t; + const _attestType46: Eq<typeof _attestActual46, typeof Expected.t> = true; + + expect(T.json).toEqual(Expected.json); + }); + + it("right bounds", () => { + const { $ } = _setup(); + // should be able to differentiate between > that is part of a right + // bound and > that closes a generic instantiation + const T = $.type("box<number>5, string>=7>"); + + const Expected = type({ + box: "number>5|string>=7", + }); + const _attestActual44 = T.t; + const _attestType44: Eq<typeof _attestActual44, typeof Expected.t> = true; + + expect(T.json).toEqual(Expected.json); + }); + + it("unclosed instantiation", () => { + const { $ } = _setup(); + // @ts-expect-error + expect(() => $.type("box<0, 1")).toThrow(); + }); + + it("extra >", () => { + const { $ } = _setup(); + expect(() => + // @ts-expect-error + $.type("box<0, 1>>"), + ).toThrow(); + }); + + it("too few args", () => { + const { $ } = _setup(); + expect(() => + // @ts-expect-error + $.type("box<0,box<2 | 3>>"), + ).toThrow(); + }); + + it("too many args", () => { + const { $ } = _setup(); + expect(() => + // @ts-expect-error + $.type("box<0, box<1, 2, 3>>"), + ).toThrow(); + }); + + it("syntactic error in arg", () => { + const { $ } = _setup(); + expect(() => + // @ts-expect-error + $.type("box<1, number%0>"), + ).toThrow(); + }); + + it("semantic error in arg", () => { + const { $ } = _setup(); + expect(() => + // @ts-expect-error + $.type("box<1,string%2>"), + ).toThrow(); + }); + + it("parameter supercedes alias with same name", () => { + const types = scope({ + "box<Foo>": { + box: "Foo|Bar", + }, + Foo: "'foo'", + Bar: "'bar'", + }).export(); + + const T = types.box("'baz'"); + + const Expected = type({ box: "'baz' | 'bar'" }); + const _attestActual36 = T.t; + const _attestType36: Eq<typeof _attestActual36, typeof Expected.t> = true; + + expect(T.json).toEqual(Expected.json); + }); + + it("declaration and instantiation leading and trailing whitespace", () => { + const types = scope({ + "box< a , b >": { + box: " a | b ", + }, + actual: " box < 'foo' , 'bar' > ", + }).export(); + + const Expected = type({ + box: "'foo' | 'bar'", + }); + const _attestActual34 = types.actual.t; + const _attestType34: Eq<typeof _attestActual34, typeof Expected.t> = true; + + expect(Expected.json).toEqual(types.actual.json); + }); + + it("allows external scope reference to be resolved", () => { + const types = scope({ + external: "'external'", + "orExternal<t>": "t|external", + }).export(); + + const b = scope({ + orExternal: types.orExternal, + internal: "orExternal<'internal'>", + }).export(); + + const Expected = type("'internal' | 'external'"); + const _attestActual32 = b.internal.t; + const _attestType32: Eq<typeof _attestActual32, typeof Expected.t> = true; + + expect(b.internal.json).toEqual(Expected.json); + }); + + it("empty string in declaration", () => { + expect(() => + scope({ + // @ts-expect-error + "box<t,,u>": "string", + }).export(), + ).toThrow(); + }); +}); + +it.todo("args completions from type"); + +describe("standalone", () => { + const _genericSetup = () => + type.generic([ + "t", + { + foo: "number", + }, + ])({ boxOf: "t" }); + it("valid", () => { + const g = _genericSetup(); + const T = g({ + foo: "number", + }); + + const Expected = type({ + boxOf: { + foo: "number", + }, + }); + const _attestActual28 = T.t; + const _attestType28: Eq<typeof _attestActual28, typeof Expected.t> = true; + + expect(T.expression).toEqual(Expected.expression); + }); + + it("invalid", () => { + const g = _genericSetup(); + expect(() => + // @ts-expect-error + g({ + foo: "string", + }), + ).toThrow(); + }); + + it.todo("completions in instantiation"); + + it.todo("completions in contraint"); + + it("is available on type", () => { + const nonEmpty = type.generic(["s", "string"])("s > 0"); + + const Expected = type("string.alpha > 0"); + const actual = nonEmpty("string.alpha"); + const _attestActual23 = actual; + const _attestType23: Eq<typeof _attestActual23, typeof Expected> = true; + + expect(actual.expression).toEqual(Expected.expression); + }); +}); + +describe("hkt", () => { + it("builds a generic from a schema callback", () => { + class MyExternalClass<T> { + data: T; + + constructor(data: T) { + this.data = data; + } + } + + const validateExternalGeneric = type.generic("T")(args => + type("instanceof", MyExternalClass).and({ + data: args.T, + }), + ); + + const T = validateExternalGeneric({ + name: "string", + age: "number", + }); + + const Expected = type("instanceof", MyExternalClass).and({ + data: { + name: "string", + age: "number", + }, + }); + expect(T.json).toEqual(Expected.json); + }); + + it("builds callback generics with constrained parameters", () => { + const validateExternalGeneric = type.generic( + ["S", "string"], + ["N", { value: "number" }], + )(args => [args.S.atLeastLength(1), args.N]); + + const T = validateExternalGeneric("string", { value: "1" }); + expect(T.allows(["x", { value: 1 }])).toBe(true); + expect(T.allows(["", { value: 1 }])).toBe(false); + expect(() => validateExternalGeneric("string", { value: "string" })).toThrow(); + }); +}); + +describe("cyclic", () => { + it("preserves generic parameters across recursive instantiations", () => { + const types = scope({ + "alternate<a, b>": { + swap: "alternate<b, a>", + order: ["a", "b"], + }, + reference: "alternate<0, 1>", + }).export(); + + expect(typeof types.alternate).toBe("function"); + expect(types.reference.expression).toContain("order: [0, 1]"); + expect(types.reference.expression).toContain("order: [1, 0]"); + expect(types.reference.expression).toContain("alternate<0,1>"); + + type Reference = typeof types.reference.infer; + const _even: Eq<Reference["swap"]["swap"]["order"], [0, 1]> = true; + const _odd: Eq<Reference["swap"]["swap"]["swap"]["order"], [1, 0]> = true; + expect(_even && _odd).toBe(true); + + const fromCall = types.alternate("'off'", "'on'"); + expect(fromCall.expression).toContain('order: ["off", "on"]'); + expect(fromCall.expression).toContain('order: ["on", "off"]'); + expect(fromCall.expression).toMatch(/alternate<"(off|on)","(off|on)">/); + }); +}); + +describe("external", () => { + it("supports generic helpers authored with type.validate", () => { + const createBox = <const def>(of: type.validate<def>): type.instantiate<{ box: def }> => + type.raw({ + box: of, + }) as never; + + const BoxType = createBox("string"); + const _type: Eq<typeof BoxType.t, { box: string }> = true; + expect(_type).toBe(true); + expect(BoxType.allows({ box: "value" })).toBe(true); + expect(BoxType.allows({ box: 1 })).toBe(false); + }); + + it("surfaces invalid definitions from external generic helpers", () => { + const createBox = <const def>(of: type.validate<def>) => + type.raw({ + box: of, + }); + + expect(() => createBox("nummer" as never)).toThrow(); + }); +}); diff --git a/packages/omptype/test/ark/get.test.ts b/packages/omptype/test/ark/get.test.ts new file mode 100644 index 000000000..36b743820 --- /dev/null +++ b/packages/omptype/test/ark/get.test.ts @@ -0,0 +1,201 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("can get shallow roots by path", () => { + const T = type({ + foo: "string", + bar: "number|bigint", + }); + + const a = T.get("bar"); + const _type1: Eq<typeof a.infer, number | bigint> = true; + expect(a.expression).toEqual("number | bigint"); +}); + +it("can get deep roots by path", () => { + const T = type({ + foo: { + baz: "1", + }, + bar: { + quux: "2", + }, + }); + + const a = T.get("foo", "baz"); + const _type2: Eq<typeof a.t, 1> = true; + expect(a.expression).toEqual("1"); + + const b = T.get("bar", "quux"); + const _type3: Eq<typeof b.t, 2> = true; + expect(b.expression).toEqual("2"); +}); + +it("can merge across a deep union", () => { + const Base = type( + { + foo: { + bar: "0", + }, + }, + "|", + { + foo: { + bar: "1", + }, + }, + ); + + const T = Base.get("foo", "bar"); + + const _type4: Eq<typeof T.infer, 0 | 1> = true; + expect(T.expression).toEqual("0 | 1"); +}); + +it("can get index keys", () => { + const T = type({ + "[/^f/]": "0", + named: "1", + }); + + const a = T.get("foo"); + const _type5: Eq<typeof a.t, 0> = true; + expect(a.expression).toEqual("0 | undefined"); + + expect(() => T.get("bar")).toThrow("key bar is not declared"); +}); + +it("named and multiple indices", () => { + const T = type({ + "[/^f/]": { + a: "1", + }, + "[/f$/]": { b: "1" }, + foof: { c: "1" }, + }); + + const a = T.get("foo"); + + const _type6: Eq<typeof a.infer, { a: 1 }> = true; + expect(a.expression).toEqual("{ a: 1 } | undefined"); + + const b = T.get("oof"); + const _type7: Eq<typeof b.infer, { b: 1 }> = true; + expect(b.expression).toEqual("{ b: 1 } | undefined"); + + const c = T.get("fof"); + const _type8: Eq<typeof c.infer, { a: 1 } & { b: 1 }> = true; + expect(c.expression).toEqual("{ a: 1, b: 1 } | undefined"); + + const d = T.get("foof"); + + const _type9: Eq<typeof d.infer, { c: 1 }> = true; + expect(d.expression).toEqual("{ c: 1, a: 1, b: 1 }"); + + expect(() => T.get("goog").expression).toThrow("key goog is not declared"); +}); + +it("optional key adds undefined", () => { + const T = type({ + "foo?": "null", + }); + + const a = T.get("foo"); + const _type10: Eq<typeof a.t, null | undefined> = true; + expect(a.expression).toEqual("null | undefined"); +}); + +it("non-fixed array", () => { + const T = type("string[]"); + + const a = T.get("0"); + const _type11: Eq<typeof a.infer, string> = true; + expect(a.expression).toEqual("string | undefined"); + + expect(() => T.get("-1")).toThrow("key -1 is not declared"); + expect(() => T.get("5.5")).toThrow("key 5.5 is not declared"); + + expect(T.get(type.arrayIndex).expression).toEqual("string | undefined"); +}); + +it("array specific-index access access on non-tuple", () => { + const T = type({ foo: "number" }).array(); + + expect(T.get(0).expression).toEqual("{ foo: number } | undefined"); +}); + +// https://github.com/arktypeio/arktype/issues/1261 +it("nested index access on non-tuple", () => { + const Simple = type({ + id: "number", + array: type({ + name: "string", + age: "number", + }).array(), + }); + + const Arr = Simple.get("array"); + const InnerArr = Arr.get(0); + + expect(InnerArr.expression).toEqual("{ name: string, age: number } | undefined"); + InnerArr.assert({ name: "Rico", age: 25 }); +}); + +it("number access on non-tuple", () => { + const T = type({ foo: "number" }).array(); + + // Schema-valued keys are rejected with an actionable message. + expect(() => T.get(type.number as never)).toThrow("is not allowed as an array or object index"); +}); + +it("tuple", () => { + const T = type(["1", "2?"]); + + // fixed + const a = T.get(0); + const _type12: Eq<typeof a.infer, 1> = true; + expect(a.expression).toEqual("1"); + const b = T.get(1); + const _type13: Eq<typeof b.infer, 2 | undefined> = true; + expect(b.expression).toEqual("undefined | 2"); + + expect(() => T.get(2)).toThrow("key 2 is not declared"); +}); + +it("variadic tuple", () => { + const T = type(["1", "2", "...", "3[]", "4", "5"]); + + // fixed + const a = T.get(0); + const _type14: Eq<typeof a.t, 1> = true; + expect(a.expression).toEqual("1"); + + const b = T.get(1); + const _type15: Eq<typeof b.t, 2> = true; + expect(b.expression).toEqual("2"); + + // variadic + // based on length, we could narrow this to remove undefined in the future + expect(T.get("2").expression).toEqual("undefined | 3 | 4 | 5"); + expect(T.get("100").expression).toEqual("undefined | 3 | 4 | 5"); +}); + +it("deep", () => { + const T = type({ + foo: { + "[symbol]": { + bar: "1", + "baz?": "2", + }, + }, + }); + + const bar = T.get("foo", type.symbol as never, "bar"); + const _type16: Eq<typeof bar.t, 1> = true; + expect(bar.expression).toEqual("1 | undefined"); + + const baz = T.get("foo", type.symbol as never, "baz"); + const _type17: Eq<typeof baz.t, 2 | undefined> = true; + expect(baz.expression).toEqual("2 | undefined"); +}); diff --git a/packages/omptype/test/ark/group.test.ts b/packages/omptype/test/ark/group.test.ts new file mode 100644 index 000000000..db0e521ac --- /dev/null +++ b/packages/omptype/test/ark/group.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("entire expression", () => { + const T = type("(string)"); + const Expected = type("string"); + const _1: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("overrides default precedence", () => { + const T = type("(boolean|number)[]"); + const Expected = type("boolean|number").array(); + const _2: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("nested", () => { + const T = type("((boolean|number)[]|(string|undefined)[])[]"); + const _3: Eq<typeof T.infer, ((number | boolean)[] | (string | undefined)[])[]> = true; +}); + +it("empty", () => { + expect(() => { + type("()"); + }).toThrow(); +}); + +it("unmatched (", () => { + expect(() => { + type("string|(boolean|number[]"); + }).toThrow(); +}); + +it("unmatched )", () => { + expect(() => { + type("string|number[]|boolean)"); + }).toThrow(); +}); + +it("lone )", () => { + expect(() => { + type(")"); + }).toThrow(); +}); + +it("lone (", () => { + expect(() => { + type("("); + }).toThrow(); +}); + +it("deep unmatched (", () => { + expect(() => { + type("(null|(undefined|(1))|2"); + }).toThrow(); +}); + +it("deep unmatched )", () => { + expect(() => { + type("((string|number)[]|boolean))[]"); + }).toThrow(); +}); + +it("starting )", () => { + expect(() => { + type(")number("); + }).toThrow(); +}); diff --git a/packages/omptype/test/ark/imports.test.ts b/packages/omptype/test/ark/imports.test.ts new file mode 100644 index 000000000..dd53811f5 --- /dev/null +++ b/packages/omptype/test/ark/imports.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "bun:test"; +import { type BoundModule, type Module, scope, type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("threeSixtyNoScope", () => { + const threeSixtyNoScope = scope({ + three: "3", + sixty: "60", + no: "'no'", + }); + const threeSixtyNoModule = threeSixtyNoScope.export(); + + const yesScope = scope({ yes: "'yes'" }); + const yesModule = yesScope.export(); + + it("single", () => { + const types = scope({ + ...threeSixtyNoModule, + threeSixtyNo: "three|sixty|no", + }).export(); + + const _assert1: Eq< + typeof types, + Module<{ + three: 3; + sixty: 60; + no: "no"; + threeSixtyNo: 3 | 60 | "no"; + }> + > = true; + }); + + it("multiple", () => { + const base = scope({ + ...threeSixtyNoModule, + ...yesModule, + extra: "true", + }); + + const imported = scope({ + ...base.import(), + a: "three|sixty|no|yes|extra", + }); + + const exports = imported.export(); + + expect(Object.keys(exports)).toEqual(["a"]); + expect(exports.a.expression).toBe('3 | 60 | "no" | "yes" | true'); + + const _assert2: Eq<typeof exports, Module<{ a: 3 | 60 | "no" | "yes" | true }>> = true; + }); + + it("import & export", () => { + const scopeCreep = scope({ + hasCrept: "true", + }); + + const types = scope({ + ...threeSixtyNoScope.import("three", "no"), + ...scopeCreep.export(), + public: "hasCrept|three|no|private", + "#private": "string.uuid", + }).export(); + + expect(Object.keys(types)).toEqual(["hasCrept", "public"]); + + expect(types.public.json).toEqual(type("true|3|'no'|string.uuid").json); + + // have to snapshot the module since TypeScript treats it as bivariant + void types; + }); +}); + +it("docs example", () => { + const shapeScope = scope({ + // aliases with a "#" prefix are treated as private + "#baseShapeProps": { + perimeter: "number", + area: "number", + }, + ellipse: { + // when referencing a private alias, the "#" should not be included + "...": "baseShapeProps", + radii: ["number", "number"], + }, + rectangle: { + "...": "baseShapeProps", + width: "number", + height: "number", + }, + }); + + // private aliases can be referenced from any scoped definition, + // even outside the original scope + const partialShape = shapeScope.type("Partial<baseShapeProps>"); + + const _assert3: Eq< + typeof partialShape.t, + { + perimeter?: number; + area?: number; + } + > = true; + const _assert4: Eq<typeof partialShape.$, typeof shapeScope> = true; + + expect(partialShape.expression).toBe("{ perimeter?: number, area?: number }"); + + // when the scope is exported to a Module, they will not be included + // hover to see the Scope's exports + const shapeModule = shapeScope.export(); + + expect(Object.keys(shapeModule)).toEqual(["ellipse", "rectangle"]); + void shapeModule; +}); + +it("docs import example", () => { + const utilityScope = scope({ + "withId<o extends object>": { + "...": "o", + id: "string", + }, + }); + + const userModule = type.module({ + // because we use `import()` here, we can reference our utilities + // internally, but they will not be included in `userModule`. + // if we used `export()` instead, `withId` could be accessed on `userModule`. + ...utilityScope.import(), + payload: { + name: "string", + age: "number", + }, + db: "withId<payload>", + }); + + expect(Object.keys(userModule)).toEqual(["payload", "db"]); + void userModule; +}); + +it("binds destructured exports", () => { + const types = scope({ + foo: "1", + bar: "foo", + baz: "bar", + }).export("baz"); + + const _assert5: Eq< + typeof types, + BoundModule< + { + baz: 1; + }, + { + foo: 1; + bar: 1; + baz: 1; + } + > + > = true; + + const T = types.baz.or({ + foo: "foo", + bar: "bar", + baz: "baz", + }); + + const _assert6: Eq< + typeof T, + Type< + | 1 + | { + foo: 1; + bar: 1; + baz: 1; + }, + { + foo: 1; + bar: 1; + baz: 1; + } + > + > = true; + expect(T.expression).toBe("1 | { foo: 1, bar: 1, baz: 1 }"); + expect(T.$.json).toEqual({ + foo: { unit: 1 }, + bar: { unit: 1 }, + baz: { unit: 1 }, + }); +}); + +it("non-generic", () => { + const types = scope({ + foo: "bar[]", + "#bar": "boolean", + }).export(); + expect(Object.keys(types)).toEqual(["foo"]); + expect(types.foo.json).toEqual(type("boolean[]").json); + const _assert7: Eq< + typeof types, + Module<{ + foo: boolean[]; + }> + > = true; +}); + +it.todo("autocompletes private references"); + +it("errors on private reference with #", () => { + expect(() => + scope({ + xdd: "#kekw", + "#kekw": "true", + }).export(), + ).toThrow(); +}); + +it("errors on private reference with # in expression", () => { + expect(() => + scope({ + xdd: "string|#kekw", + "#kekw": "true", + }).export(), + ).toThrow(); +}); + +it("errors on public and private reference with same name", () => { + expect(() => + scope({ + kekw: "1", + "#kekw": "1", + }).export(), + ).toThrow(); + expect(() => + scope({ + "#kekw": "1", + kekw: "1", + }).export(), + ).toThrow(); +}); + +it("private generic", () => { + const types = scope({ + foo: "bar<string>[]", + "#bar<t>": ["t"], + }).export(); + + const Expected = type(["string"]).array(); + + const _assert8: Eq<typeof types.foo.t, typeof Expected.t> = true; + expect(types.foo.expression).toBe("[string][]"); + expect(types.foo.expression).toEqual(Expected.expression); +}); diff --git a/packages/omptype/test/ark/instanceOf.test.ts b/packages/omptype/test/ark/instanceOf.test.ts new file mode 100644 index 000000000..93f6e9ddc --- /dev/null +++ b/packages/omptype/test/ark/instanceOf.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("tuple expression", () => { + it("base", () => { + const T = type(["instanceof", Error]); + const _1: Eq<typeof T.infer, Error> = true; + const Expected = type.instanceOf(Error); + expect(T.json).toEqual(Expected.json); + const e = new Error(); + expect(T(e)).toEqual(e); + expect(T(e)).toEqual(e); + expect(T({}).toString()).toEqual("must be an Error (was an object)"); + expect(T(undefined).toString()).toEqual("must be an Error (was undefined)"); + }); + + it("fluent", () => { + const T = type.instanceOf(Error); + + const Expected = type(["instanceof", Error]); + + const _2: Eq<typeof T.t, typeof Expected.t> = true; + expect(T.expression).toEqual(Expected.expression); + }); + + it("inherited", () => { + const T = type(["instanceof", TypeError]); + const e = new TypeError(); + // for some reason the return of TypeError's constructor is actually + // inferred as Error? Disabling this check for now, seems like an anomaly. + // expect<TypeError>(T.infer) + expect(T(e)).toEqual(e); + expect(T(new Error()).toString()).toEqual("must be an instance of TypeError (was Error)"); + }); + it("abstract", () => { + abstract class Base { + abstract foo: string; + } + class Sub extends Base { + foo = ""; + } + const T = type(["instanceof", Base]); + const _3: Eq<typeof T.infer, Base> = true; + const sub = new Sub(); + expect(T(sub)).toEqual(sub); + }); + it("multiple branches", () => { + const T = type(["instanceof", Date, Array]); + const _4: Eq<typeof T.infer, Date | unknown[]> = true; + }); + it("non-constructor", () => { + // @ts-expect-error exercises runtime validation for untyped JavaScript callers + expect(() => type(["instanceof", () => {}])).toThrow("instanceof operands must be constructors"); + }); + + // If perf cost too high can use global type config to expand ArkEnv.preserve + it("user-defined class", () => { + class ArkClass { + isArk = true; + } + const Ark = type(["instanceof", ArkClass]); + const _5: Eq<typeof Ark.t, ArkClass> = true; + // not expanded since there are no morphs + const _6: Eq<typeof Ark.infer, ArkClass> = true; + const _7: Eq<typeof Ark.in.infer, ArkClass> = true; + const a = new ArkClass(); + expect(Ark(a)).toEqual(a); + expect(Ark({}).toString()).toEqual("must be an instance of ArkClass (was an object)"); + }); + it("bidirectional checks doesn't break pipe inference", () => { + const T = type({ + f: ["string", "=>", () => [] as unknown], + }); + // Should be inferred as {f: unknown} + const _8: Eq<typeof T.infer, { f: unknown }> = true; + }); + + it("class with private properties", () => { + class ArkClass {} + const Ark = type(["instanceof", ArkClass]); + + const _9: Eq<typeof Ark.t, ArkClass> = true; + // not expanded since there are no morphs + const _10: Eq<typeof Ark.infer, ArkClass> = true; + const _11: Eq<typeof Ark.in.infer, ArkClass> = true; + }); + + it("parse error on non-function", () => { + // @ts-expect-error exercises runtime validation for untyped JavaScript callers + expect(() => type.instanceOf({})).toThrow(); + }); +}); + +describe("root expression", () => { + it("class", () => { + const T = type("instanceof", Error); + const _12: Eq<typeof T.infer, Error> = true; + expect(T.json).toEqual(type(["instanceof", Error]).json); + }); + it("instance branches", () => { + const T = type("instanceof", Date, Map); + const _13: Eq<typeof T.infer, Date | Map<unknown, unknown>> = true; + expect(T.json).toEqual(type("Date | Map").json); + }); + it("non-constructor", () => { + // @ts-expect-error exercises runtime validation for untyped JavaScript callers + expect(() => type("instanceof", new Error())).toThrow("instanceof operands must be constructors"); + }); +}); diff --git a/packages/omptype/test/ark/intersection.test.ts b/packages/omptype/test/ark/intersection.test.ts new file mode 100644 index 000000000..94cff1996 --- /dev/null +++ b/packages/omptype/test/ark/intersection.test.ts @@ -0,0 +1,108 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("two types", () => { + const T = type("boolean&true"); + const _1: Eq<typeof T.infer, true> = true; + expect(T.json).toEqual(type("true").json); +}); + +it("intersection parsed before union", () => { + // Should be parsed as: + // 1. "0" | ("1"&"string") | "2" + // 2. "0" | "1" | "2" + const T = type("'0'|'1'&string|'2'"); + const _2: Eq<typeof T.infer, "0" | "1" | "2"> = true; + expect(T.json).toEqual(type("===", "0", "1", "2").json); +}); + +it("tuple expression", () => { + const T = type([{ a: "string" }, "&", { b: "number" }]); + const _3: Eq<typeof T.infer, { a: string; b: number }> = true; + expect(T.json).toEqual(type({ a: "string", b: "number" }).json); +}); + +it("several types", () => { + const T = type("unknown&boolean&false"); + const _4: Eq<typeof T.infer, false> = true; + expect(T.json).toEqual(type("false").json); +}); + +it("method", () => { + const T = type({ a: "string" }).and({ b: "boolean" }); + const _5: Eq<typeof T.infer, { a: string; b: boolean }> = true; + expect(T.json).toEqual(type({ a: "string", b: "boolean" }).json); +}); + +it("chained deep intersections", () => { + const B = type({ b: "boolean" }, "=>", o => [o.b]); + const T = type({ + a: ["string", "=>", s => s.length], + }) + .and({ + // unable to inline this due to: + // https://github.com/arktypeio/arktype/issues/806 + b: B, + }) + .and({ + b: { b: "true" }, + c: "'hello'", + }); + const _6: Eq< + typeof T.in.infer, + { + a: string; + b: { + b: true; + }; + c: "hello"; + } + > = true; + + const _7: Eq<typeof T.infer, { a: number; b: boolean[]; c: "hello" }> = true; +}); + +it("bad reference", () => { + expect(() => type("boolean&tru")).toThrow(); +}); + +it("double and", () => { + expect(() => type("boolean&&true")).toThrow(); +}); + +it("implicit never", () => { + expect(() => type("string&number")).toThrow("intersection of string and number is unsatisfiable"); +}); + +it("intersection with never", () => { + expect(() => type("string&never")).toThrow(); +}); + +it("left semantic error", () => { + expect(() => type("string%2&'foo'")).toThrow(); +}); + +it("right semantic error", () => { + expect(() => type("'foo'&string%2")).toThrow(); +}); + +it("chained validation error", () => { + expect(() => type({ a: "string" }).and({ b: "what" })).toThrow(); +}); + +it("error at path", () => { + expect(() => type({ a: "string" }).and({ a: "number" })).toThrow( + "intersection of string and number is unsatisfiable", + ); +}); + +it("never subtype comparisons", () => { + const MyType = type({ + something: "string", + }); + + expect(type.never.extends(MyType)).toEqual(true); + + expect(MyType.extends(type.never)).toEqual(false); +}); diff --git a/packages/omptype/test/ark/keyof.test.ts b/packages/omptype/test/ark/keyof.test.ts new file mode 100644 index 000000000..209d6c151 --- /dev/null +++ b/packages/omptype/test/ark/keyof.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it.todo("autocompletion"); + +it("root expression", () => { + const T = type("keyof", { foo: "string" }); + const _1: Eq<typeof T.t, "foo"> = true; + const Expected = type("===", "foo"); + expect(T.expression).toEqual(Expected.expression); +}); + +it("object literal", () => { + const T = type({ a: "123", b: "123" }).keyof(); + const _2: Eq<typeof T.infer, "a" | "b"> = true; + expect(T.json).toEqual(type("'a'|'b'").json); +}); + +it("overlapping union", () => { + const T = type({ a: "number", b: "boolean" }).or({ b: "number", c: "string" }).keyof(); + const _3: Eq<typeof T.infer, "b"> = true; + expect(T.json).toEqual(type("'b'").json); +}); + +it("non-overlapping union", () => { + expect(() => type({ a: "number" }).or({ b: "number" }).keyof()).toThrow(); +}); + +it("tuple expression", () => { + const T = type(["keyof", { a: "string" }]); + const _4: Eq<typeof T.infer, "a"> = true; + expect(T.json).toEqual(type("'a'").json); +}); + +it("keyof non-object in union", () => { + expect(() => type({ a: "number" }).or("bigint").keyof()).toThrow(); +}); + +it("non-object", () => { + expect(() => type("keyof undefined")).toThrow(); +}); + +it("missing operand", () => { + expect(() => type("keyof ")).toThrow(); + // it tries to autocomplete, so this is just a possible completion that would be included; +}); + +it("invalid operand", () => { + expect(() => type("keyof nope")).toThrow(); +}); + +describe("scoped", () => { + const $ = scope({ + ab: { + a: "1", + "b?": "1", + }, + bc: { + b: "1", + "c?": "1", + }, + }); + it("multiple keyofs", () => { + expect(() => $.type("keyof keyof ab")).toThrow("keyof operand must be an object"); + }); + + it("groupable", () => { + const T = $.type("(keyof ab & string)[]"); + const _5: Eq<typeof T.t, ("a" | "b")[]> = true; + expect(T.json).toEqual(type("===", "a", "b").array().json); + }); + + it("intersection precedence", () => { + const T = $.type("keyof bc & string"); + const _6: Eq<typeof T.t, "b" | "c"> = true; + expect(T.json).toEqual(type("===", "b", "c").json); + }); + + it("union precedence", () => { + const T = $.type("keyof ab | bc"); + const _7: Eq<typeof T.t, "a" | "b" | { b: 1; c?: 1 }> = true; + expect(T.expression).toEqual('"a" | "b" | { b: 1, c?: 1 }'); + }); +}); diff --git a/packages/omptype/test/ark/keywords/date.test.ts b/packages/omptype/test/ark/keywords/date.test.ts new file mode 100644 index 000000000..ee03b264d --- /dev/null +++ b/packages/omptype/test/ark/keywords/date.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("string.date", () => { + const DateString = type("string.date"); + expect(DateString("2023-01-01")).toEqual("2023-01-01"); + expect(String(DateString("foo"))).toBe('must be a parsable date (was "foo")'); + expect(String(DateString(new Date()))).toBe("must be a string (was Date)"); +}); + +it("string.date.parse", () => { + const parseDate = type("string.date.parse"); + expect(parseDate("5/21/1993")).toEqual(new Date("5/21/1993")); + expect(String(parseDate("foo"))).toBe('must be a parsable date (was "foo")'); + expect(String(parseDate(5))).toBe("must be a string (was a number)"); +}); + +it("string.date.iso", () => { + const IsoDate = type("string.date.iso"); + const d = new Date().toISOString(); + expect(IsoDate(d)).toEqual(d); + expect(String(IsoDate("05-21-1993"))).toBe('must be an ISO 8601 date (was "05-21-1993")'); +}); diff --git a/packages/omptype/test/ark/keywords/exclude.test.ts b/packages/omptype/test/ark/keywords/exclude.test.ts new file mode 100644 index 000000000..f31790d32 --- /dev/null +++ b/packages/omptype/test/ark/keywords/exclude.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + from: "0 | 1", + actual: "Exclude<from, 1>", + expected: "0", + }).export(); + + const _0: Eq<typeof types.actual.t, typeof types.expected.t> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("chained", () => { + const Excluded = type("true | 0 | 'foo'").exclude("string"); + + const Expected = type("true | 0"); + + const _0: Eq<typeof Excluded.t, typeof Expected.t> = true; + + expect(Excluded.expression).toEqual(Expected.expression); +}); diff --git a/packages/omptype/test/ark/keywords/extract.test.ts b/packages/omptype/test/ark/keywords/extract.test.ts new file mode 100644 index 000000000..e06010fdb --- /dev/null +++ b/packages/omptype/test/ark/keywords/extract.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + from: "0 | 1", + actual: "Extract<from, 1>", + expected: "1", + }).export(); + + const _0: Eq<typeof types.actual.infer, typeof types.expected.infer> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("chained", () => { + const Extracted = type("true | 0 | 'foo'").extract("boolean | number"); + + const Expected = type("true | 0"); + + const _1: Eq<typeof Extracted.infer, typeof Expected.infer> = true; + + expect(Extracted.expression).toEqual(Expected.expression); +}); diff --git a/packages/omptype/test/ark/keywords/formData.test.ts b/packages/omptype/test/ark/keywords/formData.test.ts new file mode 100644 index 000000000..4bd35efae --- /dev/null +++ b/packages/omptype/test/ark/keywords/formData.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("formData", () => { + const User = type({ + email: "string.email", + file: "File", + tags: "Array.liftFrom<string>", + }); + + const parseUserForm = type("FormData.parse").pipe(User); + + const data = new FormData(); + + if (process.version.startsWith("v18")) return; + + const file = new File([], ""); + + data.append("email", "david@arktype.io"); + data.append("file", file); + data.append("tags", "typescript"); + data.append("tags", "arktype"); + + const out = parseUserForm(data); + expect(out).toEqual({ + email: "david@arktype.io", + file, + tags: ["typescript", "arktype"], + }); + + data.set("email", "david"); + data.set("file", null); + data.append("tags", file); + + expect(String(parseUserForm(data))).toBe(`email must be an email address (was "david") +file must be a File instance (was a string) +tags[2] must be a string (was Blob)`); +}); diff --git a/packages/omptype/test/ark/keywords/format.test.ts b/packages/omptype/test/ark/keywords/format.test.ts new file mode 100644 index 000000000..cbd849970 --- /dev/null +++ b/packages/omptype/test/ark/keywords/format.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("trim", () => { + const trim = type("string.trim"); + expect(trim(" foo ")).toEqual("foo"); + expect(String(trim(5))).toBe("must be a string (was a number)"); +}); + +it("lower", () => { + const lower = type("string.lower"); + expect(lower("FOO")).toEqual("foo"); + expect(String(lower(5))).toBe("must be a string (was a number)"); +}); + +it("lower.preformatted", () => { + const Lower = type("string.lower.preformatted"); + expect(Lower("var")).toBe("var"); + expect(String(Lower("newVar"))).toBe('must be only lowercase letters (was "newVar")'); +}); + +it("upper", () => { + const upper = type("string.upper"); + expect(upper("foo")).toEqual("FOO"); + expect(String(upper(5))).toBe("must be a string (was a number)"); +}); + +it("upper.preformatted", () => { + const Upper = type("string.upper.preformatted"); + expect(Upper("VAR")).toBe("VAR"); + expect(String(Upper("CONST_VAR"))).toBe('must be only uppercase letters (was "CONST_VAR")'); + expect(String(Upper("myVar"))).toBe('must be only uppercase letters (was "myVar")'); +}); + +it("capitalize", () => { + const capitalize = type("string.capitalize"); + expect(capitalize("foo")).toEqual("Foo"); + expect(String(capitalize(5))).toBe("must be a string (was a number)"); +}); + +it("capitalize.preformatted", () => { + const Capitalized = type("string.capitalize.preformatted"); + expect(Capitalized("Foo")).toEqual("Foo"); + expect(String(Capitalized("bar"))).toBe('must be capitalized (was "bar")'); +}); + +it("normalize", () => { + const normalize = type("string.normalize"); + expect(normalize("\u00F1")).toEqual("ñ"); + expect(normalize("\u006E\u0303")).toEqual("ñ"); + expect(normalize("\u00F1")).toEqual(normalize("\u006E\u0303")); + expect(String(normalize(5))).toBe("must be a string (was a number)"); +}); diff --git a/packages/omptype/test/ark/keywords/ip.test.ts b/packages/omptype/test/ark/keywords/ip.test.ts new file mode 100644 index 000000000..c2bb9507d --- /dev/null +++ b/packages/omptype/test/ark/keywords/ip.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +const validIPv4 = "192.168.1.1"; +const validIPv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + +it("root", () => { + const Ip = type("string.ip"); + + expect(Ip(validIPv4)).toEqual(validIPv4); + expect(Ip(validIPv6)).toEqual(validIPv6); + + expect(String(Ip("192.168.1.256"))).toBe('must be an IPv4 address or an IPv6 address (was "192.168.1.256")'); + expect(String(Ip("2001:0db8:85a3:0000:0000:8a2e:0370:733g"))).toBe( + 'must be an IPv4 address or an IPv6 address (was "2001:0db8:85a3:0000:0000:8a2e:0370:733g")', + ); +}); + +it("version subtype", () => { + const Uuidv4 = type("string.ip.v4"); + + expect(Uuidv4(validIPv4)).toEqual(validIPv4); + expect(String(Uuidv4("1234"))).toBe('must be an IPv4 address (was "1234")'); + + const ipv6 = type("string.ip.v6"); + expect(ipv6(validIPv6)).toEqual(validIPv6); + + expect(String(Uuidv4(validIPv6))).toBe('must be an IPv4 address (was "2001:0db8:85a3:0000:0000:8a2e:0370:7334")'); + + expect(String(ipv6(validIPv4))).toBe('must be an IPv6 address (was "192.168.1.1")'); +}); + +it("invalid ipv6 with empty segments", () => { + const ipv6 = type("string.ip.v6"); + const out = ipv6("::%8:.-:.:"); + expect(String(out)).toBe('must be an IPv6 address (was "::%8:.-:.:")'); +}); diff --git a/packages/omptype/test/ark/keywords/json.test.ts b/packages/omptype/test/ark/keywords/json.test.ts new file mode 100644 index 000000000..dad9ea9e9 --- /dev/null +++ b/packages/omptype/test/ark/keywords/json.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("string.json", () => { + const parseJson = type("string.json"); + expect(parseJson('{"a": "hello"}')).toBe('{"a": "hello"}'); + expect(String(parseJson(123))).toBe("must be a string (was a number)"); + + expect(String(parseJson("{"))).toBe('must be a JSON string (was "{")'); +}); + +it("string.json.parse", () => { + const parseJson = type("string.json.parse"); + + expect(parseJson('{"a": "hello"}')).toEqual({ a: "hello" }); + expect(String(parseJson(123))).toBe("must be a string (was a number)"); + + expect(String(parseJson("{"))).toBe('must be a JSON string (was "{")'); +}); diff --git a/packages/omptype/test/ark/keywords/merge.test.ts b/packages/omptype/test/ark/keywords/merge.test.ts new file mode 100644 index 000000000..d04a4ba09 --- /dev/null +++ b/packages/omptype/test/ark/keywords/merge.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + base: { + "foo?": "0", + "bar?": "0", + }, + merged: { + bar: "1", + "baz?": "1", + }, + actual: "Merge<base, merged>", + expected: { + "foo?": "0", + bar: "1", + "baz?": "1", + }, + }).export(); + + const _0: Eq<typeof types.actual.t, typeof types.expected.t> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("invoked", () => { + const s = Symbol(); + const T = type.keywords.Merge( + { + "[string]": "number | bigint", + foo: "0", + [s]: "true", + }, + { + "[string]": "bigint", + "foo?": "1n", + }, + ); + + const Expected = type({ + "[string]": "bigint", + "foo?": "1n", + [s]: "true", + }); + + const _0: Eq<typeof T.t, typeof Expected.t> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("chained", () => { + const T = type({ + "[string]": "number", + "bar?": "0", + foo: "0", + }).merge({ + "foo?": "1", + baz: "1", + }); + + const Expected = type({ + "[string]": "number", + "bar?": "0", + "foo?": "1", + baz: "1", + }); + + const _0: Eq<typeof T.t, typeof Expected.t> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("non-object operand", () => { + expect(() => + type({ + foo: "0", + // @ts-expect-error -- invalid operand + }).merge("string" as never), + ).toThrow("merge requires an object schema"); +}); diff --git a/packages/omptype/test/ark/keywords/number.test.ts b/packages/omptype/test/ark/keywords/number.test.ts new file mode 100644 index 000000000..45c5218cb --- /dev/null +++ b/packages/omptype/test/ark/keywords/number.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("integer", () => { + const Integer = type("number.integer"); + expect(Integer(123)).toEqual(123); + expect(String(Integer("123"))).toBe('must be an integer (was "123")'); + expect(String(Integer(12.12))).toBe("must be an integer (was 12.12)"); +}); + +it("epoch", () => { + const Epoch = type("number.epoch"); + + // valid + expect(Epoch(1621530000)).toEqual(1621530000); + expect(Epoch(8640000000000000)).toEqual(8640000000000000); + expect(Epoch(-8640000000000000)).toEqual(-8640000000000000); + + // invalid + expect(String(Epoch("foo"))).toBe("must be a number representing a Unix timestamp (was a string)"); + expect(String(Epoch(1.5))).toBe("must be an integer representing a Unix timestamp (was 1.5)"); + expect(String(Epoch(-8640000000000001))).toBe( + "must be a Unix timestamp after -8640000000000000 (was -8640000000000001)", + ); + expect(String(Epoch(8640000000000001))).toBe( + "must be a Unix timestamp before 8640000000000000 (was 8640000000000001)", + ); +}); + +it("safe", () => { + const Safe = type("number.safe"); + + expect(Safe.allows(Number.MAX_SAFE_INTEGER)).toEqual(true); + expect(Safe.allows(Number.MIN_SAFE_INTEGER)).toEqual(true); + expect(Safe.allows(0)).toEqual(true); + expect(Safe.allows(0.5)).toEqual(true); + expect(String(Safe(Number.MAX_SAFE_INTEGER + 1))).toBe("must be at most 9007199254740991 (was 9007199254740992)"); + expect(String(Safe(Number.MIN_SAFE_INTEGER - 1))).toBe("must be at least -9007199254740991 (was -9007199254740992)"); + expect(String(Safe(Infinity))).toBe("must be at most 9007199254740991 (was Infinity)"); + expect(String(Safe(-Infinity))).toBe("must be at least -9007199254740991 (was -Infinity)"); + expect(String(Safe(NaN))).toBe("must be a number (was NaN)"); +}); + +it("doesn't allow NaN by default", () => { + expect(type.number.allows(Number.NaN)).toEqual(false); + expect(String(type.number(Number.NaN))).toBe("must be a number (was NaN)"); +}); + +it("NaN", () => { + const Nan = type("number.NaN"); + + expect(Nan.allows(Number.NaN)).toEqual(true); + expect(String(Nan(0))).toBe("must be NaN (was 0)"); +}); + +it("PositiveInfinity", () => { + const Inf = type("number.Infinity"); + expect(Inf.allows(Number.POSITIVE_INFINITY)).toEqual(true); + expect(String(Inf(0))).toBe("must be Infinity (was 0)"); + expect(String(Inf(Number.NEGATIVE_INFINITY))).toBe("must be Infinity (was -Infinity)"); +}); + +it("NegativeInfinity", () => { + const NegInf = type("number.NegativeInfinity"); + expect(NegInf.allows(Number.NEGATIVE_INFINITY)).toEqual(true); + expect(String(NegInf(0))).toBe("must be -Infinity (was 0)"); + expect(String(NegInf(Number.POSITIVE_INFINITY))).toBe("must be -Infinity (was Infinity)"); +}); diff --git a/packages/omptype/test/ark/keywords/numericStrings.test.ts b/packages/omptype/test/ark/keywords/numericStrings.test.ts new file mode 100644 index 000000000..fb2e3558e --- /dev/null +++ b/packages/omptype/test/ark/keywords/numericStrings.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("string.numeric", () => { + const NumericString = type("string.numeric"); + expect(NumericString("5")).toEqual("5"); + expect(NumericString("5.5")).toEqual("5.5"); + expect(String(NumericString("five"))).toBe('must be a well-formed numeric string (was "five")'); +}); + +it("string.numeric.parse", () => { + const parseNum = type("string.numeric.parse"); + expect(parseNum("5")).toEqual(5); + expect(parseNum("5.5")).toEqual(5.5); + expect(String(parseNum("five"))).toBe('must be a well-formed numeric string (was "five")'); +}); + +it("string.integer", () => { + const IntegerString = type("string.integer"); + expect(IntegerString("5")).toEqual("5"); + expect(String(IntegerString("5.5"))).toBe('must be a well-formed integer string (was "5.5")'); + expect(String(IntegerString("five"))).toBe('must be a well-formed integer string (was "five")'); + expect(String(IntegerString(5))).toBe("must be a string (was a number)"); + // unsafe integers are allowed within strings as long as they are not parsed + expect(IntegerString("9007199254740992")).toEqual("9007199254740992"); +}); + +it("string.integer.parse", () => { + const parseIntType = type("string.integer.parse"); + expect(parseIntType("5", 10)).toEqual(5); + expect(String(parseIntType("5.5", 10))).toBe('must be a well-formed integer string (was "5.5")'); + expect(String(parseIntType("five", 10))).toBe('must be a well-formed integer string (was "five")'); + expect(String(parseIntType(5, 10))).toBe("must be a string (was a number)"); + expect(String(parseIntType("9007199254740992", 10))).toBe('must be a safe integer string (was "9007199254740992")'); +}); diff --git a/packages/omptype/test/ark/keywords/object.test.ts b/packages/omptype/test/ark/keywords/object.test.ts new file mode 100644 index 000000000..884faaee2 --- /dev/null +++ b/packages/omptype/test/ark/keywords/object.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("Function", () => { + // should not be treated as a morph + const fnType = type("Function"); + // biome-ignore lint/complexity/noBannedTypes: built-in Function keyword test + const _0: Eq<typeof fnType.infer, Function> = true; +}); + +it("Date", () => { + // should not expand built-in classes + const dateType = type("Date"); + const _1: Eq<typeof dateType.infer, Date> = true; +}); + +describe("json", () => { + it("root", () => { + const Json = type("object.json"); + + const _2: Eq<typeof Json.infer, unknown> = true; + + expect(Json({})).toEqual({}); + expect(Json([])).toEqual([]); + expect(String(Json(5))).toBe("must be an object (was a number)"); + expect(String(Json({ foo: [5n] }))).toBe( + "foo[0] must be an object, a number, a string, false, null or true (was a bigint)", + ); + }); + + it("stringify", () => { + const stringify = type("object.json.stringify"); + + const out = stringify.assert({ foo: "bar" }); + + expect(out).toBe('{"foo":"bar"}'); + + // this error kind of sucks, should have more discriminant context + expect(String(stringify({ foo: undefined }))).toBe( + "foo must be an object, a number, a string, false, null or true (was undefined)", + ); + + // has declared out + const _3: Eq<typeof stringify.out.infer, string> = true; + expect(stringify.out.expression).toBe("string"); + }); +}); + +describe("liftArray", () => { + it("parsed", () => { + const liftNumberArray = type("Array.liftFrom<number>"); + + expect(liftNumberArray(5)).toEqual([5]); + expect(liftNumberArray([5])).toEqual([5]); + expect(String(liftNumberArray("five"))).toBe("must be a number or an object (was a string)"); + expect(String(liftNumberArray(["five"]))).toBe("[0] must be a number (was a string)"); + }); + + it("invoked", () => { + const T = type.keywords.Array.liftFrom({ data: "number" }); + + expect(T.expression).toBe("(In: { data: number } | { data: number }[]) => Out<{ data: number }[]>"); + }); +}); diff --git a/packages/omptype/test/ark/keywords/omit.test.ts b/packages/omptype/test/ark/keywords/omit.test.ts new file mode 100644 index 000000000..748e65876 --- /dev/null +++ b/packages/omptype/test/ark/keywords/omit.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + from: { + foo: "1", + "bar?": "1", + baz: "1", + "quux?": "1", + }, + actual: "Omit<from, 'foo' | 'bar'>", + expected: { + baz: "1", + "quux?": "1", + }, + }).export(); + + const _0: Eq<typeof types.actual.t, typeof types.expected.t> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("chained", () => { + const User = type({ + name: "string", + "age?": "number", + isAdmin: "boolean", + "isActive?": "boolean", + }); + + const extras = User.omit("name", "age"); + + const Expected = type({ + isAdmin: "boolean", + "isActive?": "boolean", + }); + + const _0: Eq<typeof extras.t, typeof Expected.t> = true; + + expect(extras.expression).toEqual(Expected.expression); +}); diff --git a/packages/omptype/test/ark/keywords/parse.test.ts b/packages/omptype/test/ark/keywords/parse.test.ts new file mode 100644 index 000000000..f0f421ee4 --- /dev/null +++ b/packages/omptype/test/ark/keywords/parse.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("number", () => { + const parseNum = type("string.numeric.parse"); + expect(parseNum("5")).toEqual(5); + expect(parseNum(".5")).toEqual(0.5); + expect(parseNum("5.5")).toEqual(5.5); + expect(String(parseNum("five"))).toBe('must be a well-formed numeric string (was "five")'); +}); + +it("integer", () => { + const parseIntType = type("string.integer.parse"); + expect(parseIntType("5", 10)).toEqual(5); + expect(String(parseIntType("5.5", 10))).toBe('must be a well-formed integer string (was "5.5")'); + expect(String(parseIntType("five", 10))).toBe('must be a well-formed integer string (was "five")'); + expect(String(parseIntType(5, 10))).toBe("must be a string (was a number)"); + expect(String(parseIntType("9007199254740992", 10))).toBe('must be a safe integer string (was "9007199254740992")'); +}); + +it("date", () => { + const parseDate = type("string.date.parse"); + expect(parseDate("5/21/1993")).toEqual(new Date("5/21/1993")); + expect(String(parseDate("foo"))).toBe('must be a parsable date (was "foo")'); + expect(String(parseDate(5))).toBe("must be a string (was a number)"); +}); diff --git a/packages/omptype/test/ark/keywords/partial.test.ts b/packages/omptype/test/ark/keywords/partial.test.ts new file mode 100644 index 000000000..c3a27bc8c --- /dev/null +++ b/packages/omptype/test/ark/keywords/partial.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + user: { + name: "string", + "age?": "number", + }, + actual: "Partial<user>", + expected: { + "name?": "string", + "age?": "number", + }, + }).export(); + + const _0: Eq<typeof types.actual.t, typeof types.expected.t> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("chained", () => { + const T = type({ + "[string]": "number", + foo: "1", + "bar?": "1", + }).partial(); + + const _0: Eq<typeof T.t, { [x: string]: number | undefined; foo?: 1; bar?: 1 }> = true; + + expect(T.expression).toEqual("{ [string]: number, foo?: 1, bar?: 1 }"); +}); diff --git a/packages/omptype/test/ark/keywords/pick.test.ts b/packages/omptype/test/ark/keywords/pick.test.ts new file mode 100644 index 000000000..808cf4aa9 --- /dev/null +++ b/packages/omptype/test/ark/keywords/pick.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + from: { + foo: "1", + "bar?": "1", + baz: "1", + "quux?": "1", + }, + actual: "Pick<from, 'foo' | 'bar'>", + expected: { + foo: "1", + "bar?": "1", + }, + }).export(); + + const _0: Eq<typeof types.actual.t, typeof types.expected.t> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("chained", () => { + const User = type({ + name: "string", + "age?": "number", + isAdmin: "boolean", + }); + + const BasicUser = User.pick("name", "age"); + + const Expected = type({ + name: "string", + "age?": "number", + }); + + const _0: Eq<typeof Expected.t, typeof BasicUser.t> = true; + + expect(BasicUser.expression).toEqual(Expected.expression); +}); + +it("invalid key", () => { + const User = type({ + name: "string", + }); + + expect(() => User.pick("length")).toThrow(); +}); + +it("non-structure", () => { + expect(() => type("string").pick("length")).toThrow(); +}); diff --git a/packages/omptype/test/ark/keywords/record.test.ts b/packages/omptype/test/ark/keywords/record.test.ts new file mode 100644 index 000000000..43327d413 --- /dev/null +++ b/packages/omptype/test/ark/keywords/record.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const Expected = type({ "[string]": "number" }); + + const Expression = type("Record<string, number>"); + expect(Expression.json).toEqual(Expected.json); + const _0: Eq<typeof Expression.t, typeof Expected.t> = true; +}); + +it("invoked", () => { + const Expected = type({ "[string]": "number" }); + + const T = type.keywords.Record("string", "number"); + + expect(T.json).toEqual(Expected.json); + const _0: Eq<typeof T.t, typeof Expected.t> = true; +}); + +it("invoked validation error", () => { + expect(() => type.keywords.Record("string", "string % 2")).toThrow(); +}); + +it("invoked constraint error", () => { + expect(() => type.keywords.Record("boolean", "number")).toThrow(); +}); diff --git a/packages/omptype/test/ark/keywords/required.test.ts b/packages/omptype/test/ark/keywords/required.test.ts new file mode 100644 index 000000000..bb15a7fc8 --- /dev/null +++ b/packages/omptype/test/ark/keywords/required.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("parsed", () => { + const types = scope({ + user: { + name: "string", + "age?": "number", + }, + actual: "Required<user>", + expected: { + name: "string", + age: "number", + }, + }).export(); + + const _0: Eq<typeof types.actual.infer, typeof types.expected.infer> = true; + expect(types.actual.expression).toEqual(types.expected.expression); +}); + +it("chained", () => { + const T = type({ + "[string]": "number", + foo: "1", + "bar?": "1", + }).required(); + + const _1: Eq< + typeof T.infer, + { + [x: string]: number; + foo: 1; + bar: 1; + } + > = true; + + expect(T.expression).toEqual("{ [string]: number, foo: 1, bar: 1 }"); +}); + +// https://github.com/arktypeio/arktype/issues/1156 +it("with default", () => { + const T = type({ foo: "string = 'bar'" }).required(); + + const Expected = type({ + foo: "string", + }); + + // https://github.com/arktypeio/arktype/issues/1160 + + expect(T.expression).toEqual(Expected.expression); +}); diff --git a/packages/omptype/test/ark/keywords/string.test.ts b/packages/omptype/test/ark/keywords/string.test.ts new file mode 100644 index 000000000..0417509a2 --- /dev/null +++ b/packages/omptype/test/ark/keywords/string.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("alpha", () => { + const Alpha = type("string.alpha"); + expect(Alpha("user")).toBe("user"); + expect(String(Alpha("user123"))).toBe('must be only letters (was "user123")'); +}); + +it("alphanumeric", () => { + const Alphanumeric = type("string.alphanumeric"); + expect(Alphanumeric("user123")).toBe("user123"); + expect(Alphanumeric("user")).toBe("user"); + expect(Alphanumeric("123")).toBe("123"); + expect(String(Alphanumeric("abc@123"))).toBe('must be only letters and digits 0-9 (was "abc@123")'); +}); + +it("hex", () => { + const Hex = type("string.hex"); + expect(Hex("1fA3")).toBe("1fA3"); + expect(String(Hex("0x1A3"))).toBe('must be hex characters only (was "0x1A3")'); + expect(String(Hex("V29.yZA"))).toBe('must be hex characters only (was "V29.yZA")'); + expect(String(Hex("fn5-"))).toBe('must be hex characters only (was "fn5-")'); +}); + +it("base64", () => { + const B64 = type("string.base64"); + expect(B64("fn5+")).toBe("fn5+"); + expect(B64("V29yZA==")).toBe("V29yZA=="); + expect(String(B64("V29yZA"))).toBe('must be base64-encoded (was "V29yZA")'); + expect(String(B64("V29.yZA"))).toBe('must be base64-encoded (was "V29.yZA")'); + expect(String(B64("fn5-"))).toBe('must be base64-encoded (was "fn5-")'); + + const B64url = type("string.base64.url"); + expect(B64url("fn5-")).toBe("fn5-"); + expect(B64url("V29yZA")).toBe("V29yZA"); + expect(B64url("V29yZA==")).toBe("V29yZA=="); + expect(B64url("V29yZA%3D%3D")).toBe("V29yZA%3D%3D"); + expect(String(B64url("V29.yZA"))).toBe('must be base64url-encoded (was "V29.yZA")'); + expect(String(B64url("fn5+"))).toBe('must be base64url-encoded (was "fn5+")'); +}); + +it("digits", () => { + const Digits = type("string.digits"); + expect(Digits("123")).toBe("123"); + expect(String(Digits("user123"))).toBe('must be only digits 0-9 (was "user123")'); +}); + +it("email", () => { + const Email = type("string.email"); + expect(Email("shawn@mail.com")).toBe("shawn@mail.com"); + expect(String(Email("shawn@email"))).toBe('must be an email address (was "shawn@email")'); +}); + +it("credit card", () => { + const validCC = "5489582921773376"; + expect(type.string.creditCard(validCC)).toEqual(validCC); + // Regex validation + expect(String(type.string.creditCard("0".repeat(16)))).toBe('must be a credit card number (was "0000000000000000")'); + // Luhn validation + expect(String(type.string.creditCard(`${validCC.slice(0, -1)}0`))).toBe( + 'must be a credit card number (was "5489582921773370")', + ); +}); + +it("semver", () => { + expect(type.string.semver("1.0.0")).toBe("1.0.0"); + expect(String(type.string.semver("-1.0.0"))).toBe('must be a semantic version (was "-1.0.0")'); +}); + +describe("ip", () => { + const validIPv4 = "192.168.1.1"; + const validIPv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + + it("root", () => { + const Ip = type("string.ip"); + + expect(Ip(validIPv4)).toEqual(validIPv4); + + expect(Ip(validIPv6)).toEqual(validIPv6); + + expect(String(Ip("192.168.1.256"))).toBe('must be an IPv4 address or an IPv6 address (was "192.168.1.256")'); + expect(String(Ip("2001:0db8:85a3:0000:0000:8a2e:0370:733g"))).toBe( + 'must be an IPv4 address or an IPv6 address (was "2001:0db8:85a3:0000:0000:8a2e:0370:733g")', + ); + }); + + it("version subtype", () => { + const Uuidv4 = type("string.ip.v4"); + + expect(Uuidv4(validIPv4)).toEqual(validIPv4); + expect(String(Uuidv4("1234"))).toBe('must be an IPv4 address (was "1234")'); + + expect(type.string.ip.v6(validIPv6)).toEqual(validIPv6); + + expect(String(Uuidv4(validIPv6))).toBe('must be an IPv4 address (was "2001:0db8:85a3:0000:0000:8a2e:0370:7334")'); + + expect(String(type.string.ip.v6(validIPv4))).toBe('must be an IPv6 address (was "192.168.1.1")'); + }); +}); diff --git a/packages/omptype/test/ark/keywords/tsPrimitives.test.ts b/packages/omptype/test/ark/keywords/tsPrimitives.test.ts new file mode 100644 index 000000000..e0134c05d --- /dev/null +++ b/packages/omptype/test/ark/keywords/tsPrimitives.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("string strings", () => { + const StringType = type("string"); + const _0: Eq<typeof StringType.infer, string> = true; + expect(StringType("string")).toBe("string"); +}); + +it("any", () => { + const Any = type("unknown.any"); + expect(Any.json).toEqual(type.unknown.json); + const _0: Eq<typeof Any.infer, any> = true; +}); + +it("any in expression", () => { + const T = type("string", "&", "unknown.any"); + const _0: Eq<typeof T.infer, any> = true; + expect(T.allows("value")).toBe(true); + expect(T.allows(1)).toBe(false); +}); + +it("boolean", () => { + const BooleanType = type("boolean"); + const _0: Eq<typeof BooleanType.infer, boolean> = true; + expect(BooleanType.allows(true)).toBe(true); + expect(BooleanType.allows(false)).toBe(true); + expect(BooleanType.allows(0)).toBe(false); +}); + +it("never", () => { + const Never = type("never"); + const _0: Eq<typeof Never.infer, never> = true; + expect(Never.allows(undefined)).toBe(false); +}); + +it("never in union", () => { + const T = type("string|never"); + const _0: Eq<typeof T.infer, string> = true; + expect(T.allows("value")).toBe(true); + expect(T.allows(null)).toBe(false); +}); + +it("unknown", () => { + const Unknown = type("unknown"); + expect(Unknown.allows(undefined)).toBe(true); + expect(Unknown.allows({})).toBe(true); +}); diff --git a/packages/omptype/test/ark/keywords/url.test.ts b/packages/omptype/test/ark/keywords/url.test.ts new file mode 100644 index 000000000..6d51e600b --- /dev/null +++ b/packages/omptype/test/ark/keywords/url.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("root", () => { + const Url = type("string.url"); + + const _0: Eq<typeof Url.infer, string> = true; + + expect(Url("https://arktype.io")).toBe("https://arktype.io"); + expect(String(Url("arktype"))).toBe('must be a URL string (was "arktype")'); +}); + +it("parse", () => { + const parseUrl = type("string.url.parse"); + + const _1: Eq<typeof parseUrl.infer, URL> = true; + expect(parseUrl("https://arktype.io")).toBeInstanceOf(URL); + expect(String(parseUrl("arktype"))).toBe('must be a URL string (was "arktype")'); +}); diff --git a/packages/omptype/test/ark/keywords/uuid.test.ts b/packages/omptype/test/ark/keywords/uuid.test.ts new file mode 100644 index 000000000..1a0da2050 --- /dev/null +++ b/packages/omptype/test/ark/keywords/uuid.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +const validUuidV4 = "f70b8242-dd57-4e6b-b0b7-649d997140a0"; + +const validUuidV5 = "f70b8242-dd57-5e6b-b0b7-649d997140a0"; + +it("root", () => { + const Uuid = type("string.uuid"); + expect(Uuid(validUuidV4)).toEqual(validUuidV4); + expect(String(Uuid("1234"))).toBe('must be a UUID (was "1234")'); +}); + +it("version subtype", () => { + const Uuidv4 = type("string.uuid.v4"); + + expect(Uuidv4(validUuidV4)).toEqual(validUuidV4); + expect(String(Uuidv4("1234"))).toBe('must be a UUIDv4 (was "1234")'); + + expect(type.string.uuid.v5(validUuidV5)).toEqual(validUuidV5); + + expect(String(Uuidv4(validUuidV5))).toBe('must be a UUIDv4 (was "f70b8242-dd57-5e6b-b0b7-649d997140a0")'); +}); + +it("rejects partial matches", () => { + const Uuid = type("string.uuid"); + expect(String(Uuid("dbb1e8e0-40fc-4c14-87eb-61b25d166a1b extra"))).toBe("must be a UUID (was a string (length 42))"); + expect(String(Uuid("prefix dbb1e8e0-40fc-4c14-87eb-61b25d166a1b"))).toBe( + "must be a UUID (was a string (length 43))", + ); +}); diff --git a/packages/omptype/test/ark/literal.test.ts b/packages/omptype/test/ark/literal.test.ts new file mode 100644 index 000000000..1c0765f12 --- /dev/null +++ b/packages/omptype/test/ark/literal.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("tuple expression", () => { + it("literal", () => { + const T = type(["===", 5]); + const _infer: Eq<typeof T.infer, 5> = true; + expect(_infer).toBe(true); + }); + + it("symbol with description", () => { + const ism = Symbol("ism"); + const T = type(["===", ism]); + expect(T(ism)).toBe(ism); + + const ick = Symbol("ick"); + expect(String(T(ick))).toBe("must be Symbol(ism) (was a symbol)"); + }); + + it("anonymous symbol", () => { + const anon = Symbol(); + const anonName = String(anon); + const T = type(["===", anon]); + const _infer: Eq<typeof T.infer, typeof anon> = true; + expect(_infer).toBe(true); + expect(T(anon)).toBe(anon); + expect(String(T("test"))).toBe(`must be ${anonName} (was "test")`); + }); + + it("branches", () => { + const o = { ark: true }; + const s = Symbol(); + const T = type(["===", true, "foo", 5, 1n, null, undefined, o, s]); + const _infer: Eq<typeof T.infer, true | "foo" | 5 | 1n | null | undefined | { ark: boolean } | typeof s> = true; + expect(_infer).toBe(true); + }); +}); + +describe("root expression", () => { + it("single", () => { + const T = type("===", true); + const _infer: Eq<typeof T.infer, true> = true; + expect(_infer).toBe(true); + }); + + it("branches", () => { + const o = { ark: true }; + const s = Symbol(); + const T = type("===", "foo", 5, true, null, 1n, undefined, o, s); + const _infer: Eq<typeof T.infer, true | "foo" | 5 | 1n | null | undefined | { ark: boolean } | typeof s> = true; + expect(_infer).toBe(true); + }); +}); diff --git a/packages/omptype/test/ark/match.test.ts b/packages/omptype/test/ark/match.test.ts new file mode 100644 index 000000000..0a09de2f3 --- /dev/null +++ b/packages/omptype/test/ark/match.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, it } from "bun:test"; +import { match, scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("single object", () => { + const sizeOf = type.match({ + "string|Array": v => v.length, + number: v => v, + bigint: v => v, + default: "assert", + }); + + expect(sizeOf("abc")).toEqual(3); + expect(sizeOf([1, 2, 3])).toEqual(3); + expect(sizeOf(5n)).toEqual(5n); + + const getBad = () => sizeOf(true); + expect(getBad).toThrow("must be a string, an array, a number or a bigint (was boolean)"); +}); + +it("properly infers types of inputs/outputs based on chained", () => { + const matcher = match({ string: s => s, number: n => n }) + .case("boolean", b => b) + .default("assert"); + + // properly infers the type of the output based on the input + const _attestActual78 = matcher("abc"); + const _attestType78: Eq<typeof _attestActual78, string> = true; + expect(_attestActual78).toEqual("abc"); + + const _attestActual77 = matcher(4); + const _attestType77: Eq<typeof _attestActual77, number> = true; + expect(_attestActual77).toEqual(4); + + const _attestActual76 = matcher(true); + const _attestType76: Eq<typeof _attestActual76, boolean> = true; + expect(_attestActual76).toEqual(true); + + // and properly handles unions in the input type + const _attestActual75 = matcher(0 as string | number); + const _attestType75: Eq<typeof _attestActual75, string | number> = true; + + const getBad = () => matcher(null); + const _attestActual74 = getBad; + const _attestType74: Eq<typeof _attestActual74, () => never> = true; + + // this sucks and should be improved- result of discrimination + expect(getBad).toThrow(/must be (?=[^(]*boolean)(?=[^(]*number)(?=[^(]*string).*\(was null\)/); +}); + +it("multiple case blocks", () => { + const m = match({ + "1": n => n, + "2": n => n + 2, + }).match({ + "3": n => n + 3, + default: "assert", + }); + const _attestActual72 = m(1); + const _attestType72: Eq<typeof _attestActual72, 1> = true; + expect(_attestActual72).toEqual(1); + + const _attestActual71 = m(2); + const _attestType71: Eq<typeof _attestActual71, number> = true; + expect(_attestActual71).toEqual(4); + + const _attestActual70 = m(3); + const _attestType70: Eq<typeof _attestActual70, number> = true; + expect(_attestActual70).toEqual(6); +}); + +it("default value", () => { + const m = type.match({ + string: s => s.length, + default: v => v, + }); + + expect(m("foo")).toEqual(3); + expect(m(5)).toEqual(5); +}); + +it("never", () => { + const m = type.match({ + string: s => s.length, + default: "never", + }); + + expect(m("foo")).toEqual(3); +}); + +it("within scope", () => { + const threeSixtyNoScope = scope({ three: "3", sixty: "60", no: "'no'" }); + + let threeCount = 0; + let sixtyCount = 0; + + const matcher = threeSixtyNoScope + .match({ + three: three => { + threeCount++; + const _attestActual68 = three; + const _attestType68: Eq<typeof _attestActual68, 3> = true; + + return 3; + }, + }) + .case("sixty", sixty => { + sixtyCount++; + const _attestActual67 = sixty; + const _attestType67: Eq<typeof _attestActual67, 60> = true; + + return 60; + }) + .default("assert"); + + // for assertions + matcher(3); + matcher(60); + expect(threeCount).toEqual(1); + + expect(sixtyCount).toEqual(1); +}); + +it.todo("properly propagates errors from invalid type definitions in `when`"); + +it.todo("properly propagates errors from invalid type definitions in `cases`"); + +it("semantic error in case", () => { + expect(() => + match({ + // @ts-expect-error + "boolean < 5": () => true, + }), + ).toThrow(); +}); + +it("does not accept invalid inputs at a type-level", () => { + const matcher = match + .in<string | number>() + .case("string", s => s) + .case("number", n => n) + .default("never"); + + // @ts-expect-error + expect(() => matcher(true)).toThrow("must be a string or a number (was boolean)"); +}); + +it("from exhaustive", () => { + const matcher = match + .in("string | number") + .match({ + string: s => s, + number: n => n, + }) + .default("assert"); + + // @ts-expect-error + expect(() => matcher(true)).toThrow("must be a string or a number (was boolean)"); +}); + +it.todo("argless `in` type error"); + +it("allows ordered overlapping", () => { + const m = match({ + "0 < number < 10": function _matchOverlapping1(n) { + return [0, n]; + }, + // this will never be hit since it is a subtype of a previous case + "number > 0": function _matchOverlapping2(n) { + return [1, n]; + }, + number: function _matchOverlapping3(n) { + return [2, n]; + }, + default: function _matchOverlapping4(v) { + return [3, v]; + }, + }); + + expect(m(5)).toEqual([0, 5]); + + expect(m(11)).toEqual([1, 11]); + + expect(m(0)).toEqual([2, 0]); + + expect(m(undefined)).toEqual([3, undefined]); +}); + +it("prunes subtype cases", () => { + const m = match({ + "0 < number < 10": function _matchPreservedOne(n) { + return [0, n]; + }, + // this will never be hit since it is a subtype of a previous case + "4 < number < 6": function _matchPrunedOne(n) { + return [1, n]; + }, + number: function _matchPreservedTwo(n) { + return [2, n]; + }, + default: function _matchPreservedDefault(v) { + return [3, v]; + }, + }); + expect(m(5)).toEqual([0, 5]); + expect(m(11)).toEqual([2, 11]); + expect(m(null)).toEqual([3, null]); +}); + +describe("at", () => { + it("unknown allows any key", () => { + const m = match.at("n").match({ + "0": o => `${o.n} = 0` as const, + "1": o => `${o.n} = 1` as const, + default: "never", + }); + const _attestActual51 = m({ n: 0 }); + const _attestType51: Eq<typeof _attestActual51, "0 = 0"> = true; + expect(_attestActual51).toEqual("0 = 0"); + + const _attestActual50 = m({ n: 1 }); + const _attestType50: Eq<typeof _attestActual50, "1 = 1"> = true; + expect(_attestActual50).toEqual("1 = 1"); + + // @ts-expect-error + expect(() => m({})).toThrow("n must be 0 or 1 (was missing)"); + }); + + it("in", () => { + const m = match + .in<{ kind: string }>() + .at("kind") + .case("'a'", o => { + const _attestActual48 = o; + const _attestType48: Eq< + typeof _attestActual48, + { + kind: "a"; + } + > = true; + expect(_attestActual48).toEqual({ kind: "a" }); + + return [o.kind]; + }) + .default(o => o.kind); + expect(m({ kind: "a" })).toEqual(["a"]); + + expect(m({ kind: "b" })).toEqual("b"); + + // @ts-expect-error + }); + + it("keyless in", () => { + const m = match + .in<object>() + .at("foo") + .match({ + true: t => t, + default: "assert", + }); + expect(m({ foo: true, extra: 1 })).toEqual({ foo: true, extra: 1 }); + }); + + it("at with cases param", () => { + const m = match.at("foo", { + string: function _atCasesParam1(o) { + return o.foo.length; + }, + number: function _atCasesParam2(o) { + return `${o.foo + 1}`; + }, + default: "never", + }); + expect(m({ foo: "abc" })).toEqual(3); + expect(m({ foo: 1 })).toEqual("2"); + }); + + it("at after in", () => { + const m = match + .in<{ id: 0 | 1 | 2 }>() + .at("id") + .match({ + "0": function _atAfterIn1(o) { + return o.id; + }, + // correctly inferred + number: function _atAfterIn2(o) { + return o.id; + }, + default: "never", + }); + expect(m({ id: 0 })).toEqual(0); + expect(m({ id: 2 })).toEqual(2); + }); + + it("multiple ats", () => { + expect(() => { + match + .at("foo", { + string: o => o.foo.length, + }) + // @ts-expect-error + .at("bar"); + }).toThrow(); + }); +}); + +it("attached to type", () => { + const _attestActual37 = type.match; + const _attestType37: Eq<typeof _attestActual37, typeof match> = true; + expect(_attestActual37).toEqual(match); +}); + +it("initial case", () => { + const Initial = match.case("string", Number.parseInt).default("assert"); + + const Expected = match({ + string: Number.parseInt, + default: "assert", + }); + + expect(Initial("42")).toEqual(42); + expect(Expected("42")).toEqual(42); +}); + +it("reference in object", () => { + const m = match({ + string: s => s.length, + default: "assert", + }); + + const T = type({ + foo: m, + }); + const _attestActual31 = T.t; + const _attestType31: Eq< + typeof _attestActual31, + { + foo: (In: string) => Out<number>; + } + > = true; + + expect(T({ foo: "foo" })).toEqual({ foo: 3 }); + + expect(T({ foo: 5 }).toString()).toEqual("foo must be a string (was a number)"); +}); + +it("morph key", () => { + const parseNum = type.match({ + "string.numeric.parse": function _matchMorphKey1(valid) { + return valid; + }, + default: function _matchMorphKey2() { + return null; + }, + }); + + expect(parseNum("12.34")).toEqual(12.34); + expect(parseNum(12.34)).toEqual(null); +}); + +it("fluent morph", () => { + const parseIntMatch = match + .case("string.integer.parse", function _matchFluentMorph1(valid) { + return valid; + }) + .default(function _matchFluentMorph2() { + return null; + }); + const _attestActual27 = parseIntMatch("1234", 10); + const _attestType27: Eq<typeof _attestActual27, number | null> = true; + expect(_attestActual27).toEqual(1234); + + const _attestActual26 = parseIntMatch(1234, 10); + const _attestType26: Eq<typeof _attestActual26, null> = true; + expect(_attestActual26).toEqual(null); +}); + +it("accounts for ordering during discrimination", () => { + const m = match + .case( + { + id: "string", + }, + function _matchOrderedDiscrimination1(o) { + return o.id; + }, + ) + .case( + { + kind: "'string'", + }, + function _matchOrderedDiscrimination2(o) { + return o.kind; + }, + ) + .case( + { + kind: "'number'", + }, + function _matchOrderedDiscrimination3(o) { + return o.kind; + }, + ) + .case( + { + id: "number", + }, + function _matchOrderedDiscrimination4(o) { + return o.id; + }, + ) + .default("assert"); + expect(m({ id: "id", kind: "string" })).toEqual("id"); + expect(m({ id: 5, kind: "string" })).toEqual("string"); + expect(m({ id: 5, kind: "number" })).toEqual("number"); + expect(m({ id: 5, kind: "other" })).toEqual(5); +}); + +it("allows number keys", () => { + const numeric = match({ + 0: function numericZeroCase(n) { + return `${n}` as const; + }, + 1: function numericOneCase(n) { + return `${n}` as const; + }, + default: "assert", + }); + + expect(numeric(0)).toEqual("0"); + expect(numeric(1)).toEqual("1"); + expect(() => numeric(2)).toThrow("must be 0 or 1"); +}); + +it("union inputs", () => { + const stringifyResponse = type.match({ + "true | 1": n => `${n}`, + "false | 0": n => `${n}`, + default: "assert", + }); + + expect(stringifyResponse(true)).toBe("true"); + expect(stringifyResponse(false)).toBe("false"); + expect(stringifyResponse(1)).toBe("1"); + expect(stringifyResponse(0)).toBe("0"); +}); + +it("discriminated", () => { + type Data = + | { + id: 1; + oneValue: number; + } + | { + id: 2; + twoValue: string; + }; + + const discriminateValue = match + .in<Data>() + .at("id") + .match({ + 1: o => `${o.oneValue}!`, + 2: o => o.twoValue.length, + default: "assert", + }); + + const a = discriminateValue({ id: 1, oneValue: 1 }); + expect(a).toEqual("1!"); + + const b = discriminateValue({ id: 2, twoValue: "two" }); + expect(b).toEqual(3); + + // @ts-expect-error + expect(() => discriminateValue({ oneValue: 3 })).toThrow("id must be 1 or 2 (was missing)"); +}); + +it("default ArkErrors", () => { + const m = type.match({ + string: s => s.length, + number: n => n, + default: "reject", + }); + + expect(m("foo")).toEqual(3); + expect(m(3)).toEqual(3); + expect(m(null).summary).toBe("must be a string or a number (was null)"); +}); + +it("docs example 2", () => { + const sizeOf = match({ + string: v => v.length, + number: v => v, + bigint: v => v, + // match any object with a length property + }) + .case({ length: "number" }, o => o.length) + .default("assert"); + expect(sizeOf("abc")).toEqual(3); + + expect(sizeOf({ name: "David", length: 5 })).toEqual(5); + + expect(() => sizeOf(null)).toThrow("must be a string, a number, a bigint or an object (was null)"); +}); + +it("validates in", () => { + const exclaimFoo = match.in({ foo: "string" }).at("foo", { + default: o => `${o.foo}!` as const, + }); + + const out = exclaimFoo({ foo: "foo" }); + + // ensure ArkErrors is added as a possible outcome + // since input is validated without assertion + const _attestActual13 = out; + const _attestType13: Eq<typeof _attestActual13, ArkErrors | `${string}!`> = true; + expect(_attestActual13).toEqual("foo!"); + + // @ts-expect-error + const invalid = exclaimFoo({ foo: 5 }); + expect(invalid).toBeInstanceOf(type.errors); + expect(invalid.summary).toEqual("foo must be a string (was a number)"); +}); + +it("asserts in", () => { + const fooToLength = match.in({ foo: "string" }).at("foo", { + "string > 0": o => o.foo.length, + default: "assert", + }); + + const out = fooToLength({ foo: "foo" }); + + // ensure ArkErrors is not added to output + // since result is asserted + const _attestActual10 = out; + const _attestType10: Eq<typeof _attestActual10, number> = true; + expect(_attestActual10).toEqual(3); + + // @ts-expect-error + expect(() => fooToLength({ foo: 5 })).toThrow("foo must be a string (was a number)"); +}); + +it("string matcher no in", () => { + const discriminate = match.at("kind").strings({ + a: o => o.kind, + b: o => o.kind, + c: o => o.kind, + default: "assert", + }); + + const a = discriminate({ kind: "a", value: "a" }); + const b = discriminate({ kind: "b", value: "b" }); + const c = discriminate({ kind: "c", value: "c" }); + const _attestActual7 = [a, b, c]; + const _attestType7: Eq<typeof _attestActual7, ["a", "b", "c"]> = true; + expect(_attestActual7).toEqual(["a", "b", "c"]); +}); + +type Discriminated = + | { + kind: "a"; + value: "a"; + } + | { + kind: "b"; + value: "b"; + } + | { + kind: "c"; + value: "c"; + }; + +it("string literal matcher", () => { + const discriminate = match + .in<Discriminated>() + .at("kind") + .strings({ + a: o => o.value, + b: o => o.value, + c: o => o.value, + default: "assert", + }); + + const a = discriminate({ kind: "a", value: "a" }); + const b = discriminate({ kind: "b", value: "b" }); + const c = discriminate({ kind: "c", value: "c" }); + const _attestActual6 = [a, b, c]; + const _attestType6: Eq<typeof _attestActual6, ["a", "b", "c"]> = true; + expect(_attestActual6).toEqual(["a", "b", "c"]); + + // @ts-expect-error + expect(() => discriminate({ kind: "d", value: "d" })).toThrow('kind must be "a", "b" or "c" (was "d")'); +}); + +it.todo("invalid string key"); + +it.todo("lone invalid string key"); + +it("string cases no default", () => { + const check = match + .at("foo") + .strings({ + value: o => o.foo, + }) + .default("assert"); + + const out = check({ foo: "value" }); + const _attestActual2 = out; + const _attestType2: Eq<typeof _attestActual2, "value"> = true; + expect(_attestActual2).toEqual("value"); +}); + +it("string cases no default from in", () => { + const check = match + .in({ foo: "string" }) + .at("foo") + .strings({ + value: o => o.foo, + }) + .default("assert"); + + const out = check({ foo: "value" }); + const _attestActual1 = out; + const _attestType1: Eq<typeof _attestActual1, "value"> = true; + expect(_attestActual1).toEqual("value"); +}); + +it("union at input key", () => { + type Data = { + id: 1 | 2; + value: number; + }; + + const discriminateValue = type.match + .in<Data>() + .at("id") + .match({ + 1: o => `${o.value}!`, + 2: o => o.value, + default: "assert", + }); + expect(discriminateValue({ id: 1, value: 42 })).toBe("42!"); +}); diff --git a/packages/omptype/test/ark/narrow.test.ts b/packages/omptype/test/ark/narrow.test.ts new file mode 100644 index 000000000..b92dfcba4 --- /dev/null +++ b/packages/omptype/test/ark/narrow.test.ts @@ -0,0 +1,184 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("implicit problem", () => { + function isOdd(n: number): boolean { + return n % 2 === 1; + } + const Odd = type(["number", ":", isOdd]); + const _infer: Eq<typeof Odd.infer, number> = true; + expect(Odd(1)).toEqual(1); + expect(String(Odd(2))).toBe("must be valid according to isOdd (was 2)"); +}); + +it("implicit problem anonymous", () => { + const Even = type("number", ":", n => n % 2 === 0); + expect(String(Even(1))).toBe("must be valid according to an anonymous predicate (was 1)"); +}); + +it("explicit problem", () => { + const DivisibleBy3 = type(["number", ":", (n, ctx) => n % 3 === 0 || ctx.reject("divisible by 3")]); + expect(String(DivisibleBy3(1))).toBe("must be divisible by 3 (was 1)"); +}); + +it("chained narrows", () => { + const A = type("number").narrow((n, ctx) => n % 2 === 0 || ctx.reject("divisible by 2")); + const b = A.narrow((n, ctx) => n % 3 === 0 || ctx.reject("divisible by 3")); + const DivisibleBy30 = b.narrow((n, ctx) => n % 5 === 0 || ctx.reject("divisible by 5")); + const _t: Eq<typeof DivisibleBy30.t, number> = true; + expect(String(DivisibleBy30(1))).toBe("must be divisible by 2 (was 1)"); + expect(String(DivisibleBy30(2))).toBe("must be divisible by 3 (was 2)"); + expect(String(DivisibleBy30(6))).toBe("must be divisible by 5 (was 6)"); + expect(DivisibleBy30(30)).toEqual(30); +}); + +it("problem at path", () => { + const AbEqual = type([ + { a: "number", b: "number" }, + ":", + ({ a, b }, ctx) => { + if (a === b) return true; + ctx.error({ expected: "equal to b", path: ["a"] }); + ctx.error({ expected: "equal to a", path: ["b"] }); + return false; + }, + ]); + const _t: Eq<typeof AbEqual.t, { a: number; b: number }> = true; + const _infer: Eq<typeof AbEqual.infer, { a: number; b: number }> = true; + expect(AbEqual({ a: 1, b: 1 })).toEqual({ a: 1, b: 1 }); + expect(String(AbEqual({ a: 1, b: 2 }))).toBe( + 'a must be equal to b (was {"a":1,"b":2})\nb must be equal to a (was {"a":1,"b":2})', + ); +}); + +it("functional predicate", () => { + const One = type(["number", ":", (n): n is 1 => n === 1]); + const _infer: Eq<typeof One.infer, 1> = true; +}); + +it("functional parameter inference", () => { + type Expected = number | boolean[]; + const validateNumberOrBooleanList = <T>(_value: T & Expected) => true; + const T = type(["number|boolean[]", ":", data => validateNumberOrBooleanList(data)]); + const _infer: Eq<typeof T.infer, number | boolean[]> = true; + type(["number|boolean[]", ":", (data: number | string[]) => !!data]); +}); + +it("narrow problem", () => { + const Palindrome = type([ + "string", + ":", + (s, ctx) => (s === [...s].reverse().join("") ? true : ctx.reject("a palindrome")), + ]); + const _t: Eq<typeof Palindrome.t, string> = true; + expect(Palindrome("dad")).toEqual("dad"); + expect(String(Palindrome("david"))).toBe('must be a palindrome (was "david")'); +}); + +it("narrows the output type of a morph", () => { + const T = type("string") + .pipe(function _narrowMorphOutputMorph(s) { + return s.length; + }) + .narrow(function _narrowMorphOutputNarrow(n): n is 5 { + return n === 5; + }); + const _t: Eq<typeof T.t, (In: string) => 5> = true; + expect(T("12345")).toEqual(5); + expect(String(T("1234"))).toBe("must be valid according to _narrowMorphOutputNarrow (was 4)"); +}); + +it("expression", () => { + const T = type("string", ":", (s): s is `f${string}` => s[0] === "f"); + const _infer: Eq<typeof T.infer, `f${string}`> = true; +}); + +it("narrows the output type of an morph within a single type", () => { + const T = type("string") + .pipe(s => `${s}!`) + .narrow((s): s is "foo!" => s === "foo!"); + const _t: Eq<typeof T.t, (In: string) => "foo!"> = true; + const _inferIn: Eq<typeof T.inferIn, string> = true; + const _infer: Eq<typeof T.infer, "foo!"> = true; +}); + +it("narrow then pipe", () => { + const stringify = (bigint: bigint) => bigint.toString(); + const predicate = () => true; + const A = type("bigint").narrow(predicate).pipe(stringify); + const _t: Eq<typeof A.t, (In: bigint) => string> = true; + const _in: Eq<typeof A.in.infer, bigint> = true; + const _inferIn: Eq<typeof A.inferIn, bigint> = true; + const _infer: Eq<typeof A.infer, string> = true; +}); + +it("can distill constrained built-ins", () => { + const N = type("number") + .narrow(() => true) + .pipe(() => true); + const _n1: Eq<typeof N.inferIn, number> = true; + const _n2: Eq<typeof N.in.infer, number> = true; + const S = type("string") + .narrow(() => true) + .pipe(() => true); + const _s1: Eq<typeof S.inferIn, string> = true; + const _s2: Eq<typeof S.in.infer, string> = true; + const B = type("bigint") + .narrow(() => true) + .pipe(() => true); + const _b1: Eq<typeof B.inferIn, bigint> = true; + const _b2: Eq<typeof B.in.infer, bigint> = true; + const Sym = type("symbol") + .narrow(() => true) + .pipe(() => true); + const _sym1: Eq<typeof Sym.inferIn, symbol> = true; + const _sym2: Eq<typeof Sym.in.infer, symbol> = true; + const D = type("Date") + .narrow(() => true) + .pipe(() => true); + const _d: Eq<typeof D.inferIn, Date> = true; +}); + +it("can distill constrained objects", () => { + const Obj = type({ foo: "number" }) + .narrow(() => true) + .pipe(() => true); + const _obj1: Eq<typeof Obj.inferIn, { foo: number }> = true; + const _obj2: Eq<typeof Obj.in.infer, { foo: number }> = true; + const Nested = type({ foo: ["number.integer", "=>", n => n++] }); + const _nested1: Eq<typeof Nested.inferIn, { foo: number }> = true; + const _nested2: Eq<typeof Nested.in.infer, { foo: number }> = true; + const MapType = type("Map") + .narrow(() => true) + .pipe(m => m); + const _mapOut: Eq<typeof MapType.infer, Map<unknown, unknown>> = true; + const _mapIn: Eq<typeof MapType.inferIn, Map<unknown, unknown>> = true; +}); + +it("can distill constrained arrays", () => { + const Arr = type("string[]") + .narrow(() => true) + .pipe(() => true); + const _arr1: Eq<typeof Arr.inferIn, string[]> = true; + const _arr2: Eq<typeof Arr.in.infer, string[]> = true; + const ObjArr = type({ foo: "string.date.parse" }) + .array() + .narrow(() => true) + .pipe(d => d); + const _objArr1: Eq<typeof ObjArr.inferIn, { foo: string }[]> = true; + const _objArr2: Eq<typeof ObjArr.in.infer, { foo: string }[]> = true; +}); + +it("can distill units", () => { + const T = type("5").narrow(() => true); + const _t: Eq<typeof T.t, 5> = true; + const _infer: Eq<typeof T.infer, 5> = true; + const _inferIn: Eq<typeof T.inferIn, 5> = true; +}); + +it("unknown is narrowable", () => { + const unknownPredicate854 = () => true; + const T = type("unknown").narrow(unknownPredicate854); + const _t: Eq<typeof T.t, unknown> = true; +}); diff --git a/packages/omptype/test/ark/nary.test.ts b/packages/omptype/test/ark/nary.test.ts new file mode 100644 index 000000000..97035306d --- /dev/null +++ b/packages/omptype/test/ark/nary.test.ts @@ -0,0 +1,725 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("union", () => { + const $ = type.scope({ + a: "1", + b: "2", + c: "3", + d: "4", + e: "5", + f: "6", + g: "7", + h: "8", + i: "9", + j: "10", + k: "11", + l: "12", + m: "13", + n: "14", + o: "15", + p: "16", + q: "17", + }); + + it("nullary", () => { + const T = type.or(); + const _1: Eq<typeof T.t, never> = true; + + expect(T.expression).toEqual("never"); + expect(T.$.internal.name).toEqual("ark"); + }); + + it("unary", () => { + const T = $.type.or("a"); + const _2: Eq<typeof T.t, 1> = true; + expect(T.expression).toEqual("1"); + }); + + it("binary", () => { + const T = $.type.or("a", "b"); + const _3: Eq<typeof T.t, 1 | 2> = true; + expect(T.expression).toEqual("1 | 2"); + }); + + it("3-ary", () => { + const T = $.type.or("a", "b", "c"); + const _4: Eq<typeof T.t, 1 | 2 | 3> = true; + expect(T.expression).toEqual("1 | 2 | 3"); + }); + + it("4-ary", () => { + const T = $.type.or("a", "b", "c", "d"); + const _5: Eq<typeof T.t, 1 | 2 | 3 | 4> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4"); + }); + + it("5-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e"); + const _6: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5"); + }); + + it("6-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f"); + const _7: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6"); + }); + + it("7-ary", () => { + const T = $.type.or("1", "2", "3", "4", "5", "6", "7"); + const _8: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7"); + }); + + it("8-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h"); + const _9: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8"); + }); + + it("9-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i"); + const _10: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9"); + }); + + it("10-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j"); + const _11: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10"); + }); + + it("11-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + const _12: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11"); + }); + + it("12-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"); + const _13: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12"); + }); + + it("13-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m"); + const _14: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13"); + }); + + it("14-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n"); + const _15: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14"); + }); + + it("15-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o"); + const _16: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15"); + }); + + it("16-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p"); + const _17: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16"); + }); + + it("n-ary", () => { + const T = $.type.or("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q"); + + const _18: Eq<typeof T.t, 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17> = true; + expect(T.expression).toEqual("1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17"); + }); + + it.todo("completions"); + + it("spreadable", () => { + const types: type[] = []; + + const T = type.or(...types); + + const _19: Eq<typeof T.t, unknown> = true; + expect(T.expression).toEqual("never"); + }); + + it("spreadable scoped", () => { + const types: { key: "a" }[] = []; + + const T = $.type.or(...types); + + const _20: Eq<typeof T.t, { key: 1 }> = true; + }); +}); + +describe("intersection", () => { + const $ = type.scope({ + a: { a1: "1" }, + b: { a2: "2" }, + c: { a3: "3" }, + d: { a4: "4" }, + e: { a5: "5" }, + }); + + it("nullary", () => { + const T = type.and(); + const _21: Eq<typeof T.t, unknown> = true; + expect(T.expression).toEqual("unknown"); + expect(T.$.internal.name).toEqual("ark"); + }); + + it("unary", () => { + const T = $.type.and("a"); + const _22: Eq<typeof T.t, { a1: 1 }> = true; + expect(T.expression).toEqual("{ a1: 1 }"); + }); + + it("binary", () => { + const T = $.type.and("a", "b"); + const _23: Eq<typeof T.t, { a1: 1; a2: 2 }> = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2 }"); + }); + + it("3-ary", () => { + const T = $.type.and("a", "b", "c"); + const _24: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + } + > = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2, a3: 3 }"); + }); + + it("4-ary", () => { + const T = $.type.and("a", "b", "c", "d"); + const _25: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + a4: 4; + } + > = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2, a3: 3, a4: 4 }"); + }); + + it("5-ary", () => { + const T = $.type.and("a", "b", "c", "d", "e"); + const _26: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + a4: 4; + a5: 5; + } + > = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2, a3: 3, a4: 4, a5: 5 }"); + }); + + it.todo("completions"); + + it("spreadable", () => { + const types: type[] = []; + + const T = type.and(...types); + + const _27: Eq<typeof T.t, unknown> = true; + expect(T.expression).toEqual("unknown"); + }); + + it("spreadable n-length", () => { + const types: ({ a: { key: "1" } } | { "a?": { another: "1" }; "b?": "3" })[] = []; + + const T = $.type.and(...types); + + const _28: Eq< + typeof T.t, + { + // should be required if one or branches is required + a: { + key: 1; + another: 1; + }; + // should be optional if all branches are optional + b?: 3; + } + > = true; + }); +}); + +describe("merge", () => { + const $ = type.scope({ + a: { a1: "1" }, + b: { a2: "2" }, + c: { a3: "3" }, + d: { a4: "4" }, + e: { a5: "5" }, + }); + + it("nullary", () => { + const T = type.merge(); + const _29: Eq<typeof T.t, object> = true; + expect(T.expression).toEqual("object"); + expect(T.$.internal.name).toEqual("ark"); + }); + + it("unary", () => { + const T = $.type.merge("a"); + const _30: Eq<typeof T.t, { a1: 1 }> = true; + expect(T.expression).toEqual("{ a1: 1 }"); + }); + + it("binary", () => { + const T = $.type.merge("a", "b"); + const _31: Eq<typeof T.t, { a1: 1; a2: 2 }> = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2 }"); + }); + + it("3-ary", () => { + const T = $.type.merge("a", "b", "c"); + const _32: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + } + > = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2, a3: 3 }"); + }); + + it("4-ary", () => { + const T = $.type.merge("a", "b", "c", "d"); + const _33: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + a4: 4; + } + > = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2, a3: 3, a4: 4 }"); + }); + + it("5-ary", () => { + const T = $.type.merge("a", "b", "c", "d", "e"); + + const _34: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + a4: 4; + a5: 5; + } + > = true; + expect(T.expression).toEqual("{ a1: 1, a2: 2, a3: 3, a4: 4, a5: 5 }"); + }); + + it("nary", () => { + const T = type.merge( + { a1: "1" }, + { a2: "2" }, + { a3: "3" }, + { a4: "4" }, + { a5: "5" }, + { a6: "6" }, + { a7: "7" }, + { a8: "8" }, + { a9: "9" }, + { a10: "10" }, + { a11: "11" }, + { a12: "12" }, + { a13: "13" }, + { a14: "14" }, + { a15: "15" }, + { a16: "16" }, + { a17: "17" }, + ); + + const _35: Eq< + typeof T.t, + { + a1: 1; + a2: 2; + a3: 3; + a4: 4; + a5: 5; + a6: 6; + a7: 7; + a8: 8; + a9: 9; + a10: 10; + a11: 11; + a12: 12; + a13: 13; + a14: 14; + a15: 15; + a16: 16; + a17: 17; + } + > = true; + + expect(T.expression).toEqual( + "{ a1: 1, a2: 2, a3: 3, a4: 4, a5: 5, a6: 6, a7: 7, a8: 8, a9: 9, a10: 10, a11: 11, a12: 12, a13: 13, a14: 14, a15: 15, a16: 16, a17: 17 }", + ); + }); + + // type-perf currently blows up here, investigation: + // https://github.com/arktypeio/arktype/issues/1394 + + it.todo("completions"); + + it("spreadable", () => { + const types: type<object>[] = []; + + const T = type.merge(...types); + + // biome-ignore lint/complexity/noBannedTypes: empty object type assertion test + const _36: Eq<typeof T.t, {}> = true; + expect(T.expression).toEqual("object"); + }); + + it("spreadable scoped", () => { + const types: { key: "a" }[] = []; + + const T = $.type.merge(...types); + + const _37: Eq<typeof T.t, { key: { a1: 1 } }> = true; + }); + + it("spreadable n-length", () => { + const types: ({ a: "1" } | { a: "2?"; b: "3" })[] = []; + + const T = $.type.merge(...types); + + const _38: Eq< + typeof T.t, + { + // should be optional if one or more branches are optional + a?: 1 | 2; + b: 3; + } + > = true; + }); +}); + +describe("pipe", () => { + it("nullary", () => { + const T = type.pipe(); + const _39: Eq<typeof T.t, unknown> = true; + expect(T.expression).toEqual("unknown"); + expect(T.$.internal.name).toEqual("ark"); + }); + + it("unary Type", () => { + const T = type.pipe(type.string); + const _40: Eq<typeof T.t, string> = true; + expect(T.expression).toEqual("string"); + }); + + it("unary morph", () => { + const T = type.pipe((u: unknown) => JSON.stringify(u)); + const _unaryMorph: Eq<typeof T.t, (In: unknown) => Out<string>> = true; + expect(T.expression).toEqual("(In: unknown) => Out<unknown>"); + }); + + it("binary", () => { + const T = type.pipe(type.string, function _upper(s: string) { + return s.toUpperCase(); + }); + const _binaryMorph: Eq<typeof T.t, (In: string) => Out<string>> = true; + expect(T.expression).toEqual("(In: string) => Out<unknown>"); + }); + + it("3-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + ); + const _41: Eq<typeof T.infer, "abc"> = true; + expect(T("a")).toEqual("abc"); + }); + + it("4-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + ); + const _42: Eq<typeof T.infer, "abcd"> = true; + expect(T("a")).toEqual("abcd"); + }); + + it("5-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + ); + const _43: Eq<typeof T.infer, "abcde"> = true; + expect(T("a")).toEqual("abcde"); + }); + + it("6-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + ); + const _44: Eq<typeof T.infer, "abcdef"> = true; + expect(T("a")).toEqual("abcdef"); + }); + + it("7-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + ); + const _45: Eq<typeof T.infer, "abcdefg"> = true; + expect(T("a")).toEqual("abcdefg"); + }); + + it("8-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + ); + const _46: Eq<typeof T.infer, "abcdefgh"> = true; + expect(T("a")).toEqual("abcdefgh"); + }); + + it("9-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + ); + const _47: Eq<typeof T.infer, "abcdefghi"> = true; + expect(T("a")).toEqual("abcdefghi"); + }); + + it("10-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + ); + const _48: Eq<typeof T.infer, "abcdefghij"> = true; + expect(T("a")).toEqual("abcdefghij"); + }); + + it("11-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + ); + const _49: Eq<typeof T.infer, "abcdefghijk"> = true; + expect(T("a")).toEqual("abcdefghijk"); + }); + + it("12-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + ); + const _50: Eq<typeof T.infer, "abcdefghijkl"> = true; + expect(T("a")).toEqual("abcdefghijkl"); + }); + + it("13-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + s => `${s}m` as const, + ); + const _51: Eq<typeof T.infer, "abcdefghijklm"> = true; + expect(T("a")).toEqual("abcdefghijklm"); + }); + + it("14-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + s => `${s}m` as const, + s => `${s}n` as const, + ); + const _52: Eq<typeof T.infer, "abcdefghijklmn"> = true; + expect(T("a")).toEqual("abcdefghijklmn"); + }); + + it("15-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + s => `${s}m` as const, + s => `${s}n` as const, + s => `${s}o` as const, + ); + const _53: Eq<typeof T.infer, "abcdefghijklmno"> = true; + expect(T("a")).toEqual("abcdefghijklmno"); + }); + + it("16-ary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + s => `${s}m` as const, + s => `${s}n` as const, + s => `${s}o` as const, + s => `${s}p` as const, + ); + const _54: Eq<typeof T.infer, "abcdefghijklmnop"> = true; + expect(T("a")).toEqual("abcdefghijklmnop"); + }); + + it("nary", () => { + const T = type.pipe( + type.unit("a"), + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + s => `${s}m` as const, + s => `${s}n` as const, + s => `${s}o` as const, + s => `${s}p` as const, + s => `${s}q` as const, + ); + const _55: Eq<typeof T.infer, "abcdefghijklmnopq"> = true; + expect(T("a")).toEqual("abcdefghijklmnopq"); + }); + + it("spreadable as Types", () => { + const types: type[] = []; + + const T = type.pipe(...types); + + const _56: Eq<typeof T.t, unknown> = true; + expect(T.expression).toEqual("unknown"); + }); + + it("spreadable as Morphs", () => { + const morphs: Morph[] = []; + + const T = type.pipe(...morphs); + + const _57: Eq<typeof T.t, unknown> = true; + expect(T.expression).toEqual("unknown"); + }); +}); + +it("handles base scopes correctly", () => { + // previously errored here because after the first intersection, this was a SchemaScope + const T = type.and({ a1: "1" }).and({ a2: "2" }); + + expect(T.expression).toEqual("{ a1: 1, a2: 2 }"); + expect(T.$.internal.name).toEqual("ark"); +}); diff --git a/packages/omptype/test/ark/objects/defaults.test.ts b/packages/omptype/test/ark/objects/defaults.test.ts new file mode 100644 index 000000000..91bddebd2 --- /dev/null +++ b/packages/omptype/test/ark/objects/defaults.test.ts @@ -0,0 +1,771 @@ +import { describe, expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +describe("parsing and traversal", () => { + it("base", () => { + const fnDefaultTo5 = () => 5 as const; + const O = type({ + a: "string", + foo: "number = 5", + bar: ["number", "=", 5], + baz: ["number", "=", fnDefaultTo5], + }); + + const _typeBase: Eq< + typeof O.t, + { + a: string; + foo: Default<number, 5>; + bar: Default<number, 5>; + baz: Default<number, 5>; + } + > = true; + const _type2: Eq< + typeof O.inferIn, + { + a: string; + foo?: number; + bar?: number; + baz?: number; + } + > = true; + const _type3: Eq<typeof O.infer, { a: string; foo: number; bar: number; baz: number }> = true; + + expect(O({ a: "", foo: 4, bar: 4, baz: 4 })).toEqual({ + a: "", + foo: 4, + bar: 4, + baz: 4, + }); + expect(O({ a: "" })).toEqual({ a: "", foo: 5, bar: 5, baz: 5 }); + expect(O({ bar: 4 }).toString()).toBe("a must be a string (was missing)"); + expect(O({ a: "", bar: "" }).toString()).toBe("bar must be a number (was a string)"); + }); + + // https://github.com/arktypeio/arktype/issues/1335 + it("jitless", () => { + const types = type.module( + { + foo: { + test: "string = 'test'", + }, + }, + { jitless: true }, + ); + + expect(types.foo({})).toEqual({ test: "test" }); + expect(types.foo({ test: "provided" })).toEqual({ test: "provided" }); + }); + + it("unions are defaultable", () => { + const O = type({ + boo: "boolean = false", + }); + // this should not distribute to Default<true, true> | Default<false, true> + + expect(O({})).toEqual({ boo: false }); + expect(O({ boo: true })).toEqual({ boo: true }); + expect(O({ boo: 5 }).toString()).toBe("boo must be boolean (was a number)"); + }); + + it("validated default in scope", () => { + const types = scope({ + specialNumber: "number", + stringDefault: { foo: "string", bar: "specialNumber = 5" }, + tupleDefault: { foo: "string", bar: ["specialNumber", "=", 5] }, + }).export(); + + const _type16: Eq<{ + foo: string; + bar: Default<number, 5>; + }> = true; + + const _type17: Eq<typeof types.tupleDefault.t, typeof types.stringDefault.t> = true; + + expect(types.stringDefault.json).toEqual({ + required: [{ key: "foo", value: "string" }], + optional: [ + { + default: 5, + key: "bar", + value: "number", + }, + ], + domain: "object", + }); + + expect(types.tupleDefault.json).toEqual(types.stringDefault.json); + }); + + it("no shallow default in tuple expression", () => { + expect(() => + // @ts-expect-error + type(["string = 'foo'", "|", "number"]), + ).toThrow("default"); + + expect(() => + // @ts-expect-error + type(["string", "|", ["number", "=", 5]]), + ).toThrow("default"); + }); + + it("no shallow default in scope", () => { + // @ts-expect-error + expect(() => type.module({ foo: "string = ''" })).toThrow("default"); + + expect(() => + // @ts-expect-error + type.module({ foo: ["string", "=", ""] }), + ).toThrow("default"); + }); + + it("chained", () => { + const DefaultedString = type("string").default(""); + + const O = type({ a: DefaultedString }); + const _typeChained: Eq<typeof O.t, { a: Default<string, ""> }> = true; + const _type26: Eq<typeof O.inferIn, { a?: string }> = true; + const _type27: Eq<typeof O.infer, { a: string }> = true; + }); + + it("unassignable default tuple", () => { + expect(() => + // @ts-expect-error + type({ foo: "string", bar: ["number", "=", "5"] }), + ).toThrow("ParseError: Default for bar must be a number (was a string)"); + }); + + it("unassignable default thunk tuple", () => { + expect(() => + type({ + foo: [ + { foo: "true" }, + "=", + () => ({ + // @ts-expect-error + foo: false, + }), + ], + }), + ).toThrow("ParseError: Default for foo.foo must be true (was false)"); + }); + + it("unassignable default string", () => { + // @ts-expect-error + expect(() => type({ foo: "number = true" })).toThrow( + "ParseError: Default for foo must be a number (was boolean)", + ); + }); + + it("morphed", () => { + // https://discord.com/channels/957797212103016458/1280932672029593811/1283368602355109920 + const ProcessForm = type({ + bool_value: type("string") + .pipe(v => v === "on") + .default("off"), + }); + + const _typeMorphed: Eq<typeof ProcessForm.t, { bool_value: (In: Default<string, "off">) => Out<boolean> }> = true; + + const _type32: Eq< + typeof ProcessForm.inferIn, + { + // key should still be distilled as optional even inside a morph + bool_value?: string; + } + > = true; + const _type33: Eq< + typeof ProcessForm.infer, + { + bool_value: boolean; + } + > = true; + + const out = ProcessForm({}); + + expect(out).toEqual({ bool_value: false }); + + expect(ProcessForm({ bool_value: "on" })).toEqual({ bool_value: true }); + + expect(ProcessForm({ bool_value: true }).toString()).toBe("bool_value must be a string (was boolean)"); + }); + + it("primitive morph precomputed", () => { + let callCount = 0; + + const toggle = (b: boolean) => { + callCount++; + return !b; + }; + + const T = type({ + blep: type("boolean").pipe(toggle).default(false), + }); + + const _typePrecomputed: Eq<typeof T.t, { blep: (In: Default<boolean, false>) => Out<boolean> }> = true; + + const out = T({}); + + expect(out).toEqual({ blep: true }); + expect(callCount).toEqual(1); + + T({}); + expect(callCount).toEqual(1); + }); + + it("default preserved on pipe to node", () => { + let callCount = 0; + + const toggle = (b: boolean) => { + callCount++; + return !b; + }; + + const T = type({ + blep: type("boolean").pipe(toggle).to("boolean").default(false), + }); + + const _typePipeTo: Eq<typeof T.t, { blep: (In: Default<boolean, false>) => To<boolean> }> = true; + + const out = T({}); + + expect(out).toEqual({ blep: true }); + expect(callCount).toEqual(1); + + T({}); + expect(callCount).toEqual(1); + }); + + it("primitive morphed to object not premorphed", () => { + const T = type({ + foo: type("string") + .pipe(s => ({ nest: s })) + .default("foo"), + }); + const _typeMorphedObject: Eq<typeof T.t, { foo: (In: Default<string, "foo">) => Out<{ nest: string }> }> = true; + + const out = T.assert({}); + + expect(out).toEqual({ foo: { nest: "foo" } }); + + const originalOut = structuredClone(out); + + out.foo.nest = "baz"; + + expect(T({})).toEqual(originalOut); + }); +}); + +describe("string parsing", () => { + it("number", () => { + const T = type({ key: "number = 42" }); + const Expected = type({ key: ["number", "=", 42] }); + + const _type49: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("bigint", () => { + const T = type({ key: "bigint = 100n" }); + const Expected = type({ key: ["bigint", "=", 100n] }); + + const _type51: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("string", () => { + const T = type({ key: 'string = "default value"' }); + const Expected = type({ key: ["string", "=", "default value"] }); + + const _type53: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("Date", () => { + const T = type({ key: 'Date = d"1993-05-21"' }); + + const out = T.assert({}); + + expect(out.key.toISOString()).toBe("1993-05-21T00:00:00.000Z"); + + // we can't check expected here since the Date instance will not + // have a narrowed literal type + const _type56: Eq<{ + key: Default<Date, Date>; + }> = true; + }); + + it("Date is immutable", () => { + const T = type({ date: 'Date = d"1993-05-21"' }); + const v1 = T.assert({}); + const time = v1.date.getTime(); + v1.date.setMilliseconds(123); + const v2 = T.assert({}); + expect(v2.date.getTime()).toEqual(time); + }); + + it("true", () => { + const T = type({ key: "boolean = true" }); + const Expected = type({ key: ["boolean", "=", true] }); + + const _type58: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("false", () => { + const T = type({ key: "boolean = false" }); + const Expected = type({ key: ["boolean", "=", false] }); + + const _type60: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("null", () => { + // ideally we could infer a better type here, + // but attaching attributes to null or undefined + // is not possible with the current design + const T = type({ key: "object | null = null" }); + const Expected = type({ key: ["object | null", "=", null] }); + + const _type62: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("undefined", () => { + const T = type({ key: "unknown = undefined" }); + const Expected = type({ key: ["unknown", "=", undefined] }); + + expect(T({})).toEqual({ key: undefined }); + + const _type65: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); + }); + + it("incorrect default type", () => { + // @ts-expect-error + expect(() => type({ foo: "string", bar: "number = true" })).toThrow( + "ParseError: Default for bar must be a number (was boolean)", + ); + }); + + it("non-literal", () => { + expect(() => + // @ts-expect-error + type({ foo: "string", bar: "unknown = number" }), + ).toThrow("default"); + }); + + it("validated default in scope", () => { + const $ = scope({ + specialNumber: "number", + obj: { foo: "string", bar: "specialNumber = 5" }, + }); + + $.export(); + }); + + it("optional with default", () => { + expect(() => + // @ts-expect-error + type({ foo: "string", "bar?": "number = 5" }), + ).toThrow("default"); + + expect(() => + // @ts-expect-error + type({ foo: "string", "bar?": ["number", "=", 5] }), + ).toThrow("default"); + }); + + it("index with default", () => { + expect(() => + // @ts-expect-error + type({ foo: "string", "[string]": "number = 5" }), + ).toThrow("default"); + + expect(() => + // @ts-expect-error + type({ foo: "string", "[string]": ["number", "=", 5] }), + ).toThrow("default"); + }); + + it("shallow default", () => { + // @ts-expect-error + expect(() => type("string='foo'")).toThrow("default"); + + // @ts-expect-error + expect(() => type(["string", "=", "foo"])).toThrow("default"); + }); + + it("defaultable input extracted as optional", () => { + const T = type({ foo: "number = 0" }); + const _type76: Eq<typeof T.in.t, { foo?: number }> = true; + const _type77: Eq<typeof T.inferIn, { foo?: number }> = true; + + expect(T.in.expression).toBe("{ foo?: number }"); + }); + + it("defaultable output extracted as required", () => { + const T = type({ foo: "number = 0" }); + const _type79: Eq<typeof T.out.t, { foo: number }> = true; + const _type80: Eq<typeof T.inferOut, { foo: number }> = true; + + expect(T.out.expression).toBe("{ foo: number }"); + }); + + // https://github.com/arktypeio/arktype/issues/1507 + it("fails on expression value", () => { + expect(() => + type({ + // @ts-expect-error + test: "'y' | 'n' = 'n' |> 'y'", + }), + ).toThrow("unexpected"); + }); +}); + +describe("works properly with types", () => { + it("allows primitives and factories for anys", () => { + const fn = () => {}; + const T = type({ + foo1: ["unknown", "=", true], + bar1: ["unknown", "=", () => [true]], + baz1: ["unknown", "=", () => fn], + foo2: ["unknown.any", "=", true], + bar2: ["unknown.any", "=", () => [true]], + baz2: ["unknown.any", "=", () => fn], + }); + const out = T.assert({}); + expect(out).toEqual({ + foo1: true, + bar1: [true], + baz1: fn, + foo2: true, + bar2: [true], + baz2: fn, + }); + }); + it("disallows plain objects for anys", () => { + expect(() => { + // @ts-expect-error + type({ foo: ["unknown", "=", { foo: "bar" }] }); + }).toThrow("default"); + + expect(() => { + // @ts-expect-error + type({ foo: ["unknown.any", "=", { foo: "bar" }] }); + }).toThrow("default"); + }); + + it("allows string subtyping", () => { + type({ + foo: [/^foo/ as type.cast<`foo${string}`>, "=", "foobar"], + bar: [/bar$/ as type.cast<`${string}bar`>, "=", () => "foobar" as const], + }); + }); + + describe("bad values", () => { + it("primitive", () => { + expect( + // @ts-expect-error + () => type({ foo: ["number", "=", true] }), + ).toThrow("ParseError: Default for foo must be a number (was boolean)"); + }); + + it("array", () => { + expect( + // @ts-expect-error + () => type({ foo: ["number[]", "=", true] }), + ).toThrow("ParseError: Default for foo must be an array (was boolean)"); + }); + + it("object", () => { + expect( + // @ts-expect-error + () => type({ foo: [{ bar: "false" }, "=", true] }), + ).toThrow("ParseError: Default for foo must be an object (was boolean)"); + }); + + it("union", () => { + expect( + // @ts-expect-error + () => type({ foo: [["number[]", "|", "string"], "=", true] }), + ).toThrow("ParseError: Default for foo must be an array or a string (was boolean)"); + }); + + it("union with default", () => { + // should not cause "instantiation is excessively deep" + expect( + // @ts-expect-error + () => type("number[]", "|", "string").default(true), + ).toThrow("ParseError: Default must be an array or a string (was boolean)"); + }); + + it("union with default function", () => { + // should not cause "instantiation is excessively deep" + expect( + // @ts-expect-error + () => type("number[]", "|", "string").default(() => true), + ).toThrow("ParseError: Default must be an array or a string (was boolean)"); + }); + }); + + describe("morph input errors", () => { + it("string", () => { + // @ts-expect-error + expect(() => type({ foo: ["string.numeric.parse = true"] })).toThrow("must be a string (was boolean)"); + }); + + it("tuple", () => { + // @ts-expect-error + expect(() => type({ foo: ["string.numeric.parse", "=", true] })).toThrow("must be a string (was boolean)"); + }); + + it("function", () => { + // @ts-expect-error + expect(() => type({ foo: ["string.numeric.parse", "=", () => true] })).toThrow( + "must be a string (was boolean)", + ); + }); + + it("reference tuple", () => { + const Numtos = type("number").pipe(s => `${s}`); + // @ts-expect-error + expect(() => type({ foo: [Numtos, "=", true] })).toThrow("must be a number (was boolean)"); + }); + + it("reference function", () => { + const Numtos = type("number").pipe(s => `${s}`); + // @ts-expect-error + expect(() => type({ foo: [Numtos, "=", () => true] })).toThrow("must be a number (was boolean)"); + }); + }); + + it("morphed inputs", () => { + const Numtos = type("number").pipe(s => `${s}`); + const F = type({ + foo1: "string.numeric.parse = '123'", + foo2: ["string.numeric.parse", "=", "123"], + foo3: ["string.numeric.parse", "=", () => "123"], + bar1: [Numtos, "=", 123], + bar2: [Numtos, "=", () => 123], + baz1: type(Numtos).default(123), + }); + expect(F.assert({})).toEqual({ + foo1: 123, + foo2: 123, + foo3: 123, + bar1: "123", + bar2: "123", + baz1: "123", + }); + }); + + it("pipes from undefined or not present", () => { + const defaultDate = new Date("2020-01-01"); + + const ParsedDate = type("string | undefined").pipe((input: string | undefined) => + input ? new Date(input) : defaultDate, + ); + + const SearchSchema = type({ + week: ParsedDate.default(defaultDate.toISOString()), + }); + + expect(SearchSchema({ week: "2023-01-01" })).toEqual({ + week: new Date("2023-01-01"), + }); + + expect(SearchSchema({ week: undefined })).toEqual({ + week: defaultDate, + }); + + expect(SearchSchema({})).toEqual({ week: defaultDate }); + }); +}); + +describe("intersection", () => { + it("two optionals, one default", () => { + const L = type({ bar: ["number", "=", 5] }); + const R = type({ "bar?": "5" }); + + const T = L.and(R); + expect(T({})).toEqual({ bar: 5 }); + expect(T({ bar: 5 })).toEqual({ bar: 5 }); + }); + + it("same default", () => { + const L = type({ bar: ["number", "=", 5] }); + const R = type({ bar: ["5", "=", 5] }); + + const T = L.and(R); + expect(T({})).toEqual({ bar: 5 }); + expect(T({ bar: 5 })).toEqual({ bar: 5 }); + }); + + it("removed when intersected with required", () => { + const L = type({ bar: ["number", "=", 5] }); + const R = type({ bar: "number" }); + + const T = L.and(R); + expect(T({}).toString()).toBe("bar must be a number (was missing)"); + expect(T({ bar: 7 })).toEqual({ bar: 7 }); + }); + + it("errors on multiple defaults", () => { + const L = type({ bar: ["number", "=", 5] }); + const R = type({ bar: ["number", "=", 6] }); + expect(() => L.and(R)).toThrow("ParseError: Invalid intersection of default values 5 & 6"); + }); +}); + +describe("functions", () => { + it("works in tuple", () => { + const T = type({ foo: ["string", "=", () => "bar" as const] }); + + const _typeTupleFactory: Eq<typeof T.t, { foo: Default<string, "bar"> }> = true; + expect(T.assert({ foo: "bar" })).toEqual({ foo: "bar" }); + }); + + it("checks the returned value", () => { + expect(() => { + // @ts-expect-error + type({ foo: ["number", "=", () => "bar"] }); + }).toThrow("ParseError: Default for foo must be a number (was a string)"); + + expect(() => { + // @ts-expect-error + type({ foo: ["number[]", "=", () => "bar"] }); + }).toThrow("ParseError: Default for foo must be an array (was a string)"); + + expect(() => { + // @ts-expect-error + type({ foo: [{ a: "number" }, "=", () => ({ a: "bar" })] }); + }).toThrow("ParseError: Default for foo.a must be a number (was a string)"); + }); + + it("morphs the returned value", () => { + const T = type({ foo: ["string.numeric.parse", "=", () => "123"] }); + + const _typeMorphFactory: Eq<typeof T.t, { foo: (In: Default<string, string>) => To<number> }> = true; + expect(T.assert({})).toEqual({ foo: 123 }); + }); + + it("only allows argless functions for factories", () => { + expect(() => { + // @ts-expect-error + type({ bar: ["Function", "=", class {}] }); + }).toThrow("Cannot call a class constructor without |new|"); + expect(() => { + // @ts-expect-error + type({ bar: ["number", "=", (a: number) => a] }); + }).toThrow("must be a number"); + }); + + it("default factory may return different values", () => { + let i = 0; + const T = type({ bar: type("number[]").default(() => [++i]) }); + expect(T.assert({}).bar).toEqual([3]); + expect(T.assert({}).bar).toEqual([4]); + }); + + it("default function factory", () => { + let i = 0; + const T = type({ + bar: type("Function").default(() => { + const j = ++i; + return () => j; + }), + }); + + // biome-ignore lint/complexity/noBannedTypes: Function default test + const _typeFunctionFactory: Eq<typeof T.t, { bar: Default<Function, () => number> }> = true; + expect(T.assert({}).bar()).toEqual(3); + expect(T.assert({}).bar()).toEqual(4); + }); + + it("allows union factory", () => { + let i = 0; + const T = type({ + foo: [["number", "|", "number[]"], "=", () => (i % 2 ? ++i : [++i])], + }); + expect(T.assert({})).toEqual({ foo: 2 }); + expect(T.assert({})).toEqual({ foo: [3] }); + }); + + it("default array", () => { + const T = type({ + foo: type("number[]").default(() => [1]), + bar: type("number[]") + .pipe(v => v.map(e => e.toString())) + .default(() => [1]), + }); + const v1 = T.assert({}); + const v2 = T.assert({}); + expect(v1).toEqual({ foo: [1], bar: ["1"] }); + expect(v1.foo !== v2.foo).toBe(true); + }); + + it("default array is checked", () => { + expect(() => { + // @ts-expect-error + type({ bar: type("number[]").default(() => ["a"]) }); + }).toThrow("ParseError: Default value at [0] must be a number (was a string)"); + + expect(() => { + type({ + baz: type("number[]") + .pipe(v => v.map(e => e.toString())) + // @ts-expect-error + .default(() => ["a"]), + }); + }).toThrow("ParseError: Default value at [0] must be a number (was a string)"); + }); + + it("default object", () => { + const T = type({ + foo: type({ "foo?": "string" }).default(() => ({})), + bar: type({ "foo?": "string" }).default(() => ({ foo: "foostr" })), + baz: type({ foo: "string = 'foostr'" }).default(() => ({})), + }); + + const v1 = T.assert({}), + v2 = T.assert({}); + + expect(v1).toEqual({ + foo: {}, + bar: { foo: "foostr" }, + baz: { foo: "foostr" }, + }); + expect(v1.foo !== v2.foo).toBe(true); + }); + + it("default object is checked", () => { + expect(() => { + // @ts-expect-error + type({ foo: type({ foo: "string" }).default({}) }); + }).toThrow("default"); + + expect(() => { + type({ + // @ts-expect-error + bar: type({ foo: "number" }).default(() => ({ foo: "foostr" })), + }); + }).toThrow("ParseError: Default foo must be a number (was a string)"); + }); +}); + +it("extracted from cyclic type", () => { + const T = type({ + defaulted: "number = 0", + "nested?": "this", + }); + + const t = T.assert({}); + + expect(t).toEqual({ defaulted: 0 }); + + const _type128: Eq<typeof t.defaulted, number> = true; + const nestedDefaulted = t.nested?.defaulted; + const _type129: Eq<typeof nestedDefaulted, number | undefined> = true; +}); diff --git a/packages/omptype/test/ark/objects/indexSignatures.test.ts b/packages/omptype/test/ark/objects/indexSignatures.test.ts new file mode 100644 index 000000000..e1b2cb4b7 --- /dev/null +++ b/packages/omptype/test/ark/objects/indexSignatures.test.ts @@ -0,0 +1,216 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("string index", () => { + const O = type({ "[string]": "string" }); + const _type1: Eq<typeof O.infer, { [x: string]: string }> = true; + + expect(O({})).toEqual({}); + expect(O({ a: "a", b: "b" })).toEqual({ a: "a", b: "b" }); + + const validWithSymbol = { a: "a", [Symbol()]: null }; + expect(validWithSymbol).toEqual(validWithSymbol); + + expect(O({ a: 1 }).toString()).toBe("a must be a string (was a number)"); + expect(O({ a: true, b: false }).toString()).toBe(`a must be a string (was boolean) +b must be a string (was boolean)`); +}); + +it("symbol index", () => { + const O = type({ "[symbol]": "1" }); + const _type8: Eq<typeof O.infer, { [x: symbol]: 1 }> = true; + + expect(O({})).toEqual({}); + + expect(O({ a: 999 })).toEqual({ a: 999 }); + + const zildjian = Symbol(); + const zildjianName = String(zildjian); + + // I've been dope, suspenseful with a pencil + // Ever since... + const prince = Symbol(); + const princeName = String(prince); + + expect(O({ [zildjian]: 1, [prince]: 1 })).toEqual({ + [zildjian]: 1, + [prince]: 1, + }); + + expect({ a: 0, [zildjian]: 1 }).toEqual({ a: 0, [zildjian]: 1 }); + + expect(O({ [zildjian]: 0 }).toString()).toEqual(`[${zildjianName}] must be 1 (was 0)`); + expect(O({ [prince]: null, [zildjian]: undefined }).toString()).toBe(`[${princeName}] must be 1 (was null) +[${zildjianName}] must be 1 (was undefined)`); +}); + +it("enumerable indexed union", () => { + const O = type({ "['foo' | 'bar']": "string" }); + const Expected = type({ foo: "string", bar: "string" }); + const _type16: Eq<typeof O, typeof Expected> = true; +}); + +it("non-enumerable indexed union", () => { + const O = type({ "[string | symbol]": "string" }); + const _type18: Eq<typeof O.infer, { [x: string]: string; [x: symbol]: string }> = true; +}); + +it("multiple indexed", () => { + const O = type({ + "[string]": "string", + "[symbol]": "number", + }); + const _type20: Eq<typeof O.infer, { [x: string]: string; [x: symbol]: number }> = true; + + expect(O({})).toEqual({}); + expect(O({ foo: "f" })).toEqual({ foo: "f" }); + + const sym = Symbol(); + + const symName = String(sym); + + const validWithStringsAndSymbols = { + str: "string", + [sym]: 8675309, + }; + + expect(O(validWithStringsAndSymbols)).toEqual(validWithStringsAndSymbols); + + expect( + O({ + str: 100, + [sym]: "💯", + }).toString(), + ).toEqual(`str must be a string (was a number) +[${symName}] must be a number (was a string)`); +}); + +it("pattern index", () => { + const O = type({ "[/^f/]": "number" }); + + expect(O({ foo: 1, fizz: 2, other: "unmatched" })).toEqual({ foo: 1, fizz: 2, other: "unmatched" }); + expect(O({ foo: "wrong" }).toString()).toBe("foo must be a number (was a string)"); +}); + +it("all key kinds", () => { + const O = type({ + "[string]": "string", + required: "'foo'", + "optional?": "'bar'", + }); + const _type26: Eq<typeof O.infer, { [x: string]: string; required: "foo"; optional?: "bar" }> = true; + + const valid: typeof O.infer = { required: "foo", other: "bar" }; + expect(O(valid)).toEqual(valid); + expect( + O({ + optional: "wrongString", + other: 0n, + }).toString(), + ).toBe(`required must be "foo" (was missing) +optional must be "bar" (was "wrongString") +other must be a string (was a bigint)`); +}); + +it("index key from scope", () => { + const types = scope({ + key: "symbol|'foo'|'bar'|'baz'", + obj: { + "[key]": "string", + }, + }).export(); + type Key = symbol | "foo" | "bar" | "baz"; + const _type30: Eq<typeof types.key.infer, Key> = true; + const _type31: Eq<Record<Key, string>> = true; + + const Expected = type({ "[symbol]": "string" }).and({ + foo: "string", + bar: "string", + baz: "string", + }); + expect(types.obj.json).toEqual(Expected.json); +}); + +it("intersection with named", () => { + const T = type({ "[string]": "4" }).and({ "a?": "1" }); + const _type33: Eq< + typeof T.infer, + { + [k: string]: 4; + a?: never; + } + > = true; +}); + +it("intersction with right required", () => { + const T = type({ "a?": "true" }).and({ a: "boolean" }); + const _type35: Eq<typeof T.infer, { a: true }> = true; + const Expected = type({ + a: "true", + }); + expect(T.json).toEqual(Expected.json); +}); + +it("syntax error in index definition", () => { + expect(() => + type({ + // @ts-expect-error + "[unresolvable]": "string", + }), + ).toThrow('unknown keyword "unresolvable"'); +}); + +it("does not allow syntax error message as value", () => { + expect(() => + type({ + // @ts-expect-error + "[unresolvable]": "'unresolvable' is unresolvable", + }), + ).toThrow("trailing tokens"); +}); + +it("semantic error in index definition", () => { + expect(() => + type({ + // @ts-expect-error + "[symbol<5]": "string", + }), + ).toThrow('cannot bound symbol in "symbol<5"'); +}); + +it("invalid key type for index definition", () => { + expect(() => + type({ + // @ts-expect-error + "[object]": "string", + }), + ).toThrow("indexed key definition must resolve to a string or symbol (was an object)"); +}); + +it("does not allow invalid key type error as value", () => { + expect(() => + type({ + // @ts-expect-error + "[object]": "Indexed key definition 'object' must be a string, number or symbol", + }), + ).toThrow('unknown keyword "Indexed"'); +}); + +it("escaped index", () => { + const O = type({ "\\[string]": "string" }); + const _type42: Eq<typeof O.infer, { "[string]": string }> = true; +}); + +// https://github.com/arktypeio/arktype/issues/1040 +it("can constrain optional keys", () => { + const Repro = type({ + normal: "string>0", + "optional?": "string>0", + }); + + type Expected = { normal: string; optional?: string }; + + const _type44: Eq<Expected, typeof Repro.infer> = true; + const _type45: Eq<Expected, typeof Repro.inferIn> = true; +}); diff --git a/packages/omptype/test/ark/objects/mapped.test.ts b/packages/omptype/test/ark/objects/mapped.test.ts new file mode 100644 index 000000000..2574be7d6 --- /dev/null +++ b/packages/omptype/test/ark/objects/mapped.test.ts @@ -0,0 +1,361 @@ +import { expect, it } from "bun:test"; +import { declare, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("identity", () => { + const Original = type({ + "foo?": "string", + bar: "number", + baz: "boolean", + }); + const T = Original.map(entry => entry); + + const _type1: Eq<typeof T, typeof Original> = true; + expect(T.expression).toEqual(Original.expression); +}); + +it("change values", () => { + const Original = type({ + "foo?": "string", + bar: "number", + baz: { + inner: "string", + }, + }); + + const T = Original.map(prop => { + if (prop.key === "foo") { + return { + key: prop.key, + value: prop.value.array().atLeastLength(1), + }; + } + if (prop.key === "bar") { + return { + key: prop.key, + value: prop.value.or("null"), + }; + } + if (prop.key === "baz") { + return { + key: prop.key, + value: prop.value.and({ + intersectedInner: "number", + }), + }; + } + return prop; + }); + + const Expected = type({ + "foo?": "string[] >= 1", + bar: "number | null", + baz: { + inner: "string", + intersectedInner: "number", + }, + }); + + const _type3: Eq<typeof T, typeof Expected> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("infer method output", () => { + type ExpectedKey<t = type<object>> = + | propValueOf<{ + [k in keyof t as t[k] extends Fn<never, type.Any> + ? [t[k]] extends [Fn<never, anyOrNever>] + ? never + : k + : never]: k; + }> + | "to" + | "get" + | "pipe"; + + const Base = type({ base: "1" }); + + type Base = typeof Base.infer; + + type Original = { [k in ExpectedKey]: Base }; + + const Original = declare<Original>().type({ + and: Base, + array: Base, + as: Base, + brand: Base, + configure: Base, + describe: Base, + exclude: Base, + extract: Base, + filter: Base, + get: Base, + keyof: Base, + map: Base, + merge: Base, + narrow: Base, + omit: Base, + onDeepUndeclaredKey: Base, + onUndeclaredKey: Base, + or: Base, + partial: Base, + pick: Base, + pipe: Base, + readonly: Base, + required: Base, + to: Base, + }); + + const brand = Base.brand("brand"); + + const filterFn = (v: Base): v is Base & { filter: 1 } => true; + const narrowFn = (v: Base): v is Base & { narrow: 1 } => true; + const pipeFn = () => ({ pipe: 1 }); + + const Expected = type({ + and: { base: "1", and: "1" }, + array: Base.array(), + as: Base.as<{ as: 1 }>(), + brand, + configure: Base.configure({ description: "" }), + describe: Base.describe(""), + exclude: { exclude: "1" }, + extract: { extract: "1" }, + filter: [Base, ":", filterFn], + get: "1", + keyof: "'base'", + map: { base: "2" }, + merge: { base: "1", merge: "1" }, + narrow: [Base, ":", narrowFn], + omit: {}, + onDeepUndeclaredKey: { "+": "reject", base: "1" }, + onUndeclaredKey: { "+": "reject", base: "1" }, + or: [Base, "|", { or: "1" }], + partial: { "base?": "1" }, + pick: { pick: "1" }, + pipe: [Base, "=>", pipeFn], + readonly: Base.readonly(), + required: { base: "1", required: "1" }, + to: { base: "1", to: "1" }, + }); + + const mapped = Original.map(prop => { + switch (prop.key) { + case "and": + return { + key: prop.key, + value: prop.value.and({ and: "1" }), + }; + case "array": + return { + key: prop.key, + value: prop.value.array(), + }; + case "as": + return { + key: prop.key, + value: prop.value.as<{ as: 1 }>(), + }; + case "brand": + return { + key: prop.key, + value: prop.value.brand("brand"), + }; + case "configure": + return { + key: prop.key, + value: prop.value.configure({ description: "" }), + }; + case "describe": + return { + key: prop.key, + value: prop.value.describe(""), + }; + case "exclude": + return { + key: prop.key, + value: prop.value.or({ exclude: "1" }).exclude(Base), + }; + case "extract": + return { + key: prop.key, + value: prop.value.or({ extract: "1" }).extract({ extract: "1" }), + }; + case "filter": + return { + key: prop.key, + value: prop.value.filter(filterFn), + }; + case "get": + return { + key: prop.key, + value: prop.value.get("base"), + }; + case "keyof": + return { + key: prop.key, + value: prop.value.keyof(), + }; + case "map": + return { + key: prop.key, + value: prop.value.map(innerProp => ({ + key: innerProp.key, + value: type("2"), + })), + }; + case "merge": + return { + key: prop.key, + value: prop.value.merge({ + merge: "1", + }), + }; + case "narrow": + return { + key: prop.key, + value: prop.value.narrow(narrowFn), + }; + case "omit": + return { + key: prop.key, + value: prop.value.omit("base"), + }; + case "onDeepUndeclaredKey": + return { + key: prop.key, + value: prop.value.onDeepUndeclaredKey("reject"), + }; + case "onUndeclaredKey": + return { + key: prop.key, + value: prop.value.onUndeclaredKey("reject"), + }; + case "or": + return { + key: prop.key, + value: prop.value.or({ or: "1" }), + }; + case "partial": + return { + key: prop.key, + value: prop.value.partial(), + }; + case "pick": + return { + key: prop.key, + value: prop.value.and({ pick: "1" }).pick("pick"), + }; + case "pipe": + return { + key: prop.key, + value: prop.value.pipe(pipeFn), + }; + case "readonly": + return { + key: prop.key, + value: prop.value.readonly(), + }; + case "required": + return { + key: prop.key, + value: prop.value.and({ "required?": "1" }).required(), + }; + case "to": + return { + key: prop.key, + value: prop.value.to({ to: "1" }), + }; + default: + prop satisfies never; + return prop; + } + }); + + const _type5: Eq<typeof mapped.t, typeof Expected.t> = true; +}); + +it("filter and split values", () => { + const Original = type({ + "foo?": "string", + bar: "number", + baz: { + inner: "string", + }, + }); + + const getInner = (data: typeof Original.infer.baz) => data.inner; + + const T = Original.map(prop => { + if (prop.key === "bar") return []; + + if (prop.key === "baz") { + return [ + prop, + { + key: "fromBaz" as const, + value: prop.value.pipe(getInner), + }, + ]; + } + return prop; + }); + + const Expected = type({ + "foo?": "string", + baz: { + inner: "string", + }, + fromBaz: Original.get("baz").pipe(getInner), + }); + + const _type7: Eq<typeof T, typeof Expected> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("change optionality", () => { + const Original = type({ + "foo?": "string", + bar: "number", + baz: "boolean", + }); + + const T = Original.map(prop => { + if (prop.key === "foo") return { kind: "required", key: "foo", value: prop.value } as const; + if (prop.key === "bar") return { kind: "optional", key: "bar", value: prop.value } as const; + + return prop; + }); + + const Expected = type({ + foo: "string", + "bar?": "number", + baz: "boolean", + }); + + const _type9: Eq<typeof T, typeof Expected> = true; + expect(T.expression).toEqual(Expected.expression); +}); + +it("modify default", () => { + const Original = type({ + foo: "string = 'foo'", + "bar?": "number", + }); + + const _type11: Eq<typeof Original.infer, { foo: string; bar?: number }> = true; + expect(Original({})).toEqual({ foo: "foo" }); + + const T = Original.map(prop => { + if (prop.key === "foo") { + return { + ...prop, + default: `${prop.default}t` as const, + }; + } + return prop; + }); + + const _type13: Eq<typeof T.infer, { bar?: number; foo: string }> = true; + expect(T({})).toEqual({ foo: "foot" }); +}); diff --git a/packages/omptype/test/ark/objects/namedKeys.test.ts b/packages/omptype/test/ark/objects/namedKeys.test.ts new file mode 100644 index 000000000..73fad3614 --- /dev/null +++ b/packages/omptype/test/ark/objects/namedKeys.test.ts @@ -0,0 +1,183 @@ +import { expect, it } from "bun:test"; +import { type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("empty", () => { + const O = type({}); + const _type1: Eq<typeof O.t, object> = true; + expect(O({})).toEqual({}); +}); + +it("required", () => { + const O = type({ a: "string", b: "number" }); + const _type3: Eq<typeof O.infer, { a: string; b: number }> = true; +}); + +it("optional keys", () => { + const O = type({ "a?": "string", b: "number" }); + const _type5: Eq<typeof O.infer, { a?: string; b: number }> = true; +}); + +it("chained optional", () => { + const OptionalString = type("string").optional(); + const _type7: Eq<typeof OptionalString, [Type<string>, "?"]> = true; + + const O = type({ a: OptionalString }); + // directly inferring the optional key causes recursive generics/intersections to fail, + // so instead we just distill it out like defaults + const _typeOptionalT: Eq<typeof O.t, { a?: string }> = true; + const _typeOptionalInfer: Eq<typeof O.infer, { a?: string }> = true; + const _typeOptionalInferIn: Eq<typeof O.inferIn, { a?: string }> = true; +}); + +it("string-embedded value optional", () => { + const s = Symbol("ok"); + const T = type({ [s]: "string?" }); + + const _type12: Eq< + typeof T.t, + { + [s]?: string; + } + > = true; + const _type13: Eq<typeof T.infer, { [s]?: string }> = true; +}); + +it("tuple value optional", () => { + const s = Symbol("ok"); + const T = type({ [s]: [{ foo: "string" }, "?"] }); + + const _type15: Eq<typeof T.infer, { [s]?: { foo: string } }> = true; + + expect(T({ [s]: { foo: "ok" } })).toEqual({ [s]: { foo: "ok" } }); + expect(T({ [s]: { foo: 1 } }).toString()).toContain(`[${String(s)}].foo must be a string`); +}); + +// https://github.com/arktypeio/arktype/issues/1102 +it("only optional keys not reduced to object", () => { + const O = type({ "a?": "number" }); + + const U = type({ b: O }); + const _type18: Eq< + typeof U.t, + { + b: { + a?: number; + }; + } + > = true; + const _type19: Eq<typeof U.infer, typeof U.t> = true; + const _type20: Eq<typeof U.inferIn, typeof U.t> = true; +}); + +// https://github.com/arktypeio/arktype/issues/1102 +it("optional keys in union not reduced to object", () => { + const U = type({ b: type({ "a?": "number" }).or("number") }); + const _type22: Eq< + typeof U.t, + { + b: + | { + a?: number; + } + | number; + } + > = true; +}); + +it("symbol key", () => { + const s = Symbol(); + const T = type({ + [s]: "string", + }); + const _type23: Eq<typeof T.infer, { [s]: string }> = true; + expect(T({ [s]: "ok" })).toEqual({ [s]: "ok" }); + expect(T({ [s]: 1 }).toString()).toContain(`[${String(s)}] must be a string`); +}); + +it("serializes to same value but not reference equal", () => { + const T = type("===", {}); + expect(T({}).toString()).toBe("must be reference equal to {} (serialized to the same value)"); +}); + +it("error in obj that has tuple that writes error at proper path", () => { + expect(() => + // @ts-expect-error + type({ "a?": ["string", ["stringx", "?"]] }), + ).toThrow('unknown keyword "stringx" in "stringx"'); +}); + +it("nested", () => { + const T = type({ "a?": { b: "boolean" } }); + const _type27: Eq<typeof T.infer, { a?: { b: boolean } }> = true; +}); + +it("intersections", () => { + const a = { "a?": "string" } as const; + const b = { b: "string" } as const; + const c = { "c?": "string" } as const; + const Abc = type(a).and(b).and(c); + const _type28: Eq<typeof Abc.infer, { a?: string; b: string; c?: string }> = true; +}); + +it("intersection", () => { + const T = type({ a: "number" }).and({ b: "boolean" }); + // Should be simplified from {a: number} & {b: boolean} to {a: number, b: boolean} + const _typeIntersection: Eq<typeof T.infer, { a: number; b: boolean }> = true; +}); + +it("escaped optional token", () => { + const T = type({ "a\\?": "string" }); + const _type33: Eq<typeof T.infer, { "a?": string }> = true; +}); + +it("traverse optional", () => { + const O = type({ "a?": "string" }); + expect(O({ a: "a" })).toEqual({ a: "a" }); + expect(O({})).toEqual({}); + expect(O({ a: 1 }).toString()).toBe("a must be a string (was a number)"); +}); + +it("optional symbol", () => { + const s = Symbol(); + const T = type({ + [s]: type.number.optional(), + }); + const _type38: Eq<typeof T.infer, { [s]?: number }> = true; + expect(T({})).toEqual({}); + expect(T({ [s]: 1 })).toEqual({ [s]: 1 }); +}); + +it("morphed", () => { + const ProcessForm = type({ + bool_value: type("string") + .pipe(v => v === "on") + .optional(), + }); + + const _typeMorphed: Eq<typeof ProcessForm.t, { bool_value?: (In: string) => Out<boolean> }> = true; + const _type40: Eq< + typeof ProcessForm.inferIn, + { + // key should still be distilled as optional even inside a morph + bool_value?: string; + } + > = true; + const _type41: Eq< + typeof ProcessForm.infer, + { + // out should also be inferred as optional + bool_value?: boolean; + } + > = true; + + expect(ProcessForm({})).toEqual({}); + + expect(ProcessForm({ bool_value: "on" })).toEqual({ bool_value: true }); + + expect(ProcessForm({ bool_value: true }).toString()).toBe("bool_value must be a string (was boolean)"); +}); + +it.todo("required key homomorphic"); + +it.todo("optional value homomorphic"); diff --git a/packages/omptype/test/ark/objects/onUndeclaredKey.test.ts b/packages/omptype/test/ark/objects/onUndeclaredKey.test.ts new file mode 100644 index 000000000..e43e38b64 --- /dev/null +++ b/packages/omptype/test/ark/objects/onUndeclaredKey.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("can parse an undeclared restriction", () => { + const T = type({ "+": "reject" }); + // biome-ignore lint/complexity/noBannedTypes: empty object type test + const _type1: Eq<typeof T.infer, {}> = true; + expect(T({ extra: true }).toString()).toBe("extra must be removed"); +}); + +it("fails on type definition for undeclared", () => { + // @ts-expect-error + expect(() => type({ "+": "string" })).toThrow("string"); +}); + +it("can escape undeclared meta key", () => { + const T = type({ "\\+": "string" }); + const _type4: Eq<typeof T.infer, { "+": string }> = true; + expect(T({ "+": "ok" })).toEqual({ "+": "ok" }); +}); + +describe("traversal", () => { + const getExtraneousB = () => ({ a: "ok", b: "why?" }); + + it("loose by default", () => { + const T = type({ + a: "string", + }); + + const dataWithExtraneousB = getExtraneousB(); + expect(T(dataWithExtraneousB)).toEqual(dataWithExtraneousB); + }); + + it("delete keys", () => { + const T = type({ + a: "string", + }).onUndeclaredKey("delete"); + expect(T({ a: "ok" })).toEqual({ a: "ok" }); + expect(T(getExtraneousB())).toEqual({ a: "ok" }); + }); + + it("applies shallowly", () => { + const T = type({ + a: "string", + nested: { + a: "string", + }, + }).onUndeclaredKey("delete"); + + expect( + T({ + ...getExtraneousB(), + nested: getExtraneousB(), + }), + ).toEqual({ a: "ok", nested: { a: "ok", b: "why?" } as never }); + }); + + it("can apply deeply", () => { + const T = type({ + a: "string", + nested: { + a: "string", + }, + }).onDeepUndeclaredKey("delete"); + + expect( + T({ + ...getExtraneousB(), + nested: getExtraneousB(), + }), + ).toEqual({ a: "ok", nested: { a: "ok" } }); + }); + + it("delete union key", () => { + const O = type([{ a: "string" }, "|", { a: "boolean", b: "true" }]).onUndeclaredKey("delete"); + // can distill to first branch + expect(O({ a: "to", z: "bra" })).toEqual({ a: "to" }); + // can distill to second branch + expect(O({ a: true, b: true, c: false })).toEqual({ a: true, b: true }); + // can handle missing keys + expect(O({ a: true }).toString()).toBe("a must be a string (was true) or b must be true (was missing)"); + }); + + it("fails on delete indiscriminable union key", () => { + expect(() => + type([{ a: "string" }, "|", { b: "boolean" }]).onUndeclaredKey("delete"), + ).toThrow(`ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate: +Left: { a: string, + (undeclared): delete } +Right: { b: boolean, + (undeclared): delete }`); + }); + + it("reject key", () => { + const T = type({ + a: "string", + }).onUndeclaredKey("reject"); + expect(T({ a: "ok" })).toEqual({ a: "ok" }); + expect(T(getExtraneousB()).toString()).toBe("b must be removed"); + }); + + it("reject array key", () => { + const O = type({ "+": "reject", a: "string[]" }); + expect(O({ a: ["shawn"] })).toEqual({ a: ["shawn"] }); + expect(O({ a: [2] }).toString()).toBe("a[0] must be a string (was a number)"); + expect(O({ b: ["shawn"] }).toString()).toBe(`a must be an array (was missing) +b must be removed`); + }); + + it("reject key from union", () => { + const O = type([{ a: "string" }, "|", { b: "boolean" }]).onUndeclaredKey("reject"); + expect(O({ a: 2, b: true }).toString()).toBe("a must be a string or removed (was 2)"); + }); +}); diff --git a/packages/omptype/test/ark/objects/props.test.ts b/packages/omptype/test/ark/objects/props.test.ts new file mode 100644 index 000000000..e78564c8e --- /dev/null +++ b/packages/omptype/test/ark/objects/props.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +// by default because of the toJSON method, it wouldn't be clear +// if the snapshotted props were requied or optional +const snapshottableProps = (props: array<BaseTypeProp>) => + props.map(p => ({ + kind: p.kind, + key: p.key, + value: p.value.expression, + })); + +it("strings", () => { + const T = type({ + foo: "1", + bar: "2", + "baz?": "3", + }); + + const _type1: Eq< + typeof T.props, + array< + // biome-ignore lint/complexity/noBannedTypes: BaseTypeProp generic type parameter + | BaseTypeProp<"required", "foo", 1, {}> + // biome-ignore lint/complexity/noBannedTypes: BaseTypeProp generic type parameter + | BaseTypeProp<"required", "bar", 2, {}> + // biome-ignore lint/complexity/noBannedTypes: BaseTypeProp generic type parameter + | BaseTypeProp<"optional", "baz", 3, {}> + > + > = true; + + expect(snapshottableProps(T.props)).toEqual([ + { kind: "required", key: "foo", value: "1" }, + { kind: "required", key: "bar", value: "2" }, + { kind: "optional", key: "baz", value: "3" }, + ]); +}); + +it("mixed keys", () => { + const s = Symbol(); + const s2 = Symbol(); + + const T = type({ + [s]: "1", + [s2]: ["2", "?"], + foo: "3", + foo2: ["4", "?"], + }); + + const _type3: Eq< + typeof T.infer, + { + [s]: 1; + foo: 3; + [s2]?: 2; + foo2?: 4; + } + > = true; + + expect(snapshottableProps(T.props)).toEqual([ + { kind: "required", key: "foo", value: "3" }, + { kind: "optional", key: "foo2", value: "4" }, + { kind: "required", key: s, value: "1" }, + { kind: "optional", key: s2, value: "2" }, + ]); +}); + +it("union", () => { + const T = type({ foo: "string" }).or({ bar: "number" }); + expect(() => T.props).toThrow("props"); +}); + +it("structural operation removes narrow", () => { + const T = type({ foo: { key: "string" } }) + .narrow(o => o.foo.key.length > 0) + .merge({ + foo: "null", + }); + + expect(T({ foo: null })).toEqual({ foo: null }); +}); + +it("duplicate optional key", () => { + expect(() => + type({ + a: "true", + "a?": "true", + }), + ).toThrow("a"); +}); + +it("allows prototype method names as keys", () => { + // constructor, hasOwnProperty, toString, etc. are valid object keys + // and should not be incorrectly flagged as duplicates + const T = type({ + constructor: "string", + hasOwnProperty: "number", + toString: "boolean", + }); + + const _type9: Eq< + typeof T.t, + { + constructor: string; + hasOwnProperty: number; + toString: boolean; + } + > = true; +}); diff --git a/packages/omptype/test/ark/objects/spread.test.ts b/packages/omptype/test/ark/objects/spread.test.ts new file mode 100644 index 000000000..21d4d7ae1 --- /dev/null +++ b/packages/omptype/test/ark/objects/spread.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "../type-assert"; + +it("within scope", () => { + const s = scope({ + user: { isAdmin: "false", name: "string" }, + admin: { "...": "user", isAdmin: "true" }, + }).export(); + + const _type1: Eq<typeof s.admin.infer, { isAdmin: true; name: string }> = true; + expect(s.admin({ isAdmin: true, name: "root" })).toEqual({ isAdmin: true, name: "root" }); + expect(s.admin({ isAdmin: false, name: "root" }).toString()).toContain("isAdmin must be true"); +}); + +it("from another `type` call", () => { + const User = type({ isAdmin: "false", name: "string" }); + const Admin = type({ "...": User, isAdmin: "true" }); + + const _type3: Eq<typeof Admin.infer, { isAdmin: true; name: string }> = true; + expect(Admin({ isAdmin: true, name: "root" })).toEqual({ isAdmin: true, name: "root" }); +}); + +it("from an object literal", () => { + // no idea why you'd want to do this + const T = type({ + "...": { + inherited: "boolean", + overridden: "string", + }, + overridden: "number", + }); + + const _type5: Eq< + typeof T.infer, + { + inherited: boolean; + overridden: number; + } + > = true; + + expect(T({ inherited: false, overridden: 1 })).toEqual({ inherited: false, overridden: 1 }); + expect(T({ inherited: false, overridden: "wrong" }).toString()).toContain("overridden must be a number"); +}); + +it("escaped key", () => { + const T = type({ + "\\...": "string", + }); + + const _type7: Eq<typeof T.infer, { "...": string }> = true; + + expect(T({ "...": "ok" })).toEqual({ "...": "ok" }); +}); + +it("with non-object", () => { + // @ts-expect-error + expect(() => type({ "...": "string" })).toThrow("object spread must resolve to an object literal (was a string)"); +}); + +// this is a regression test to ensure nodes are handled even if they aren't just an object +it("with complex type", () => { + const AdminUser = type({ + "...": [{ name: "string" }, "&", { isAdmin: "false" }], + isAdmin: "true", + }); + + const _type10: Eq<typeof AdminUser.infer, { isAdmin: true; name: string }> = true; + expect(AdminUser({ name: "root", isAdmin: true })).toEqual({ name: "root", isAdmin: true }); + expect(AdminUser({ name: "root", isAdmin: false }).toString()).toContain("isAdmin must be true"); +}); + +it("object keyword treated as empty", () => { + const T = type({ + "...": "object", + foo: "string", + }); + + const _type12: Eq< + typeof T.t, + { + foo: string; + } + > = true; + expect(T({ foo: "ok" })).toEqual({ foo: "ok" }); +}); + +it("narrowed object keyword treated as empty", () => { + const T = type({ + "...": type.object.narrow(() => true), + foo: "string", + }); + + const _type14: Eq< + typeof T.t, + { + foo: string; + } + > = true; + expect(T({ foo: "ok" })).toEqual({ foo: "ok" }); +}); + +it("errors on proto node", () => { + expect(() => + type({ + "...": "Date", + foo: "string", + }), + ).toThrow("object spread must resolve to an object literal (was a Date)"); +}); + +it.todo("autocompletes shallow string"); + +it.todo("autocompletes nested strings"); diff --git a/packages/omptype/test/ark/optional.test.ts b/packages/omptype/test/ark/optional.test.ts new file mode 100644 index 000000000..8afc72084 --- /dev/null +++ b/packages/omptype/test/ark/optional.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("no shallow default in tuple expression", () => { + expect(() => type(["string?", "|", "number"])).toThrow(); + + expect(() => type(["string", "|", ["number", "?"]])).toThrow(); +}); + +it("no shallow default in scope", () => { + expect(() => type.module({ foo: "string?" })).toThrow(); + + expect(() => type.module({ foo: ["string", "?"] })).toThrow(); +}); diff --git a/packages/omptype/test/ark/pipe.test.ts b/packages/omptype/test/ark/pipe.test.ts new file mode 100644 index 000000000..ca1865905 --- /dev/null +++ b/packages/omptype/test/ark/pipe.test.ts @@ -0,0 +1,937 @@ +import { describe, expect, it } from "bun:test"; +import { type ArkErrors, scope, type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +type Out<T> = T; +type To<T> = T; + +it("base", () => { + const T = type("number").pipe(data => `${data}`); + const _type1: Eq<typeof T, Type<(In: number) => Out<string>>> = true; + const _type2: Eq<typeof T.infer, string> = true; + const _type3: Eq<typeof T.in.infer, number> = true; + const out = T(5); + const _type4: Eq<typeof out, string | type.errors> = true; + expect(out).toEqual("5"); + const result = T("foo"); + expect(result.toString()).toEqual("must be a number (was a string)"); +}); + +it("disjoint", () => { + expect(() => type("number>5").pipe(type("number<3"))).toThrow("numeric range intersection is unsatisfiable"); +}); + +it("to", () => { + const T = type("string.json.parse").to({ + name: "string", + age: "number", + }); + + const tOut = T.out; + const Expected = type({ + name: "string", + age: "number", + }); + + const _type5: Eq<typeof tOut.t, typeof Expected.t> = true; + expect(tOut.expression).toEqual(Expected.expression); +}); + +describe("to string syntax", () => { + it("to validator", () => { + const trimToNonEmpty = type("string.trim |> string > 0"); + const Expected = type("string.trim").to("string > 0"); + + const _type6: Eq<typeof trimToNonEmpty, typeof Expected> = true; + expect(trimToNonEmpty(" ok ")).toEqual(Expected(" ok ")); + expect(trimToNonEmpty(" ").toString()).toEqual(Expected(" ").toString()); + }); + + it("to morph", () => { + const trimAndParseNumber = type("string.trim |> string.numeric.parse"); + const Expected = type("string.trim").to("string.numeric.parse"); + + const _type7: Eq<typeof trimAndParseNumber, typeof Expected> = true; + expect(trimAndParseNumber(" 42 ")).toEqual(Expected(" 42 ")); + }); + + it("lower precedence than union", () => { + const T = type("string.numeric.parse |> number.integer | number.safe"); + const Expected = type("string.numeric.parse").to("number.integer | number.safe"); + + const _type8: Eq<typeof T, typeof Expected> = true; + expect(T("2.5")).toEqual(Expected("2.5")); + expect(T("2")).toEqual(Expected("2")); + }); + + it("lower precedence than union reversed", () => { + const T = type("string.numeric.parse | number.integer |> number.safe"); + const Expected = type("string.numeric.parse | number.integer").to("number.safe"); + + const _type9: Eq<typeof T, typeof Expected> = true; + expect(T("2")).toEqual(Expected("2")); + expect(T(2)).toEqual(Expected(2)); + }); + + it("missing operand", () => { + expect(() => type("string |>")).toThrow(); + }); +}); + +it("to morph", () => { + const restringifyUser = (o: object) => JSON.stringify(o); + + const T = type("string.json.parse").to([ + { + name: "string", + age: "number", + }, + "=>", + restringifyUser, + ]); + + const _type10: Eq<typeof T.infer, string> = true; + expect(T('{"name":"Ada","age":37}')).toEqual('{"name":"Ada","age":37}'); +}); + +describe("try", () => { + it("can catch thrown errors", () => { + const ParseJson = type("string").pipe.try((s): object => JSON.parse(s)); + + const out = ParseJson("[]"); + + const _type11: Eq<typeof out, ArkErrors | object> = true; + expect(out).toEqual([]); + + const badOut = ParseJson("{ unquoted: true }"); + + expect(badOut.toString()).toContain("morph threw SyntaxError"); + }); + + it("preserves validated out", () => { + const T = type("string").pipe.try(s => JSON.parse(s), type("unknown[]")); + + const tOut = T.out; + const ExpectedOut = type("unknown[]"); + + const _type12: Eq<typeof tOut.t, typeof ExpectedOut.t> = true; + expect(tOut.expression).toEqual(ExpectedOut.expression); + }); +}); + +it("can't directly constrain morph", () => { + expect(() => type("string.numeric.parse").atMostLength(5)).toThrow(); +}); + +it("within type", () => { + const T = type([ + "boolean", + "=>", + function notMorph(data) { + return !data; + }, + ]); + const _type13: Eq<typeof T, Type<(In: boolean) => Out<boolean>>> = true; + + const out = T(true); + const _type14: Eq<typeof out, boolean | type.errors> = true; + expect(out).toEqual(false); + expect(T(1).toString()).toContain("must be boolean"); +}); + +it("unit branches", () => { + const T = type("0 | 1 | 2").pipe(n => n + 1); + const _type15: Eq<typeof T.t, (In: 0 | 1 | 2) => Out<number>> = true; + + expect(T(0)).toEqual(1); + expect(T(3).toString()).toContain("must be 0, 1 or 2"); +}); + +it("type instance reference", () => { + const User = type({ + name: "string", + age: "number", + }); + const parseUser = type("string").pipe(s => JSON.parse(s), User); + + const _type16: Eq<typeof parseUser.t, (In: string) => To<{ name: string; age: number }>> = true; + + const validUser = { name: "David", age: 30 }; + expect(parseUser(JSON.stringify(validUser))).toEqual(validUser); + const missingKey = { name: "David" }; + expect(parseUser(JSON.stringify(missingKey)).toString()).toEqual("age must be a number (was missing)"); +}); + +it("many pipes", () => { + const pipeAlphabet = type("'a'").pipe( + s => `${s}b` as const, + s => `${s}c` as const, + s => `${s}d` as const, + s => `${s}e` as const, + s => `${s}f` as const, + s => `${s}g` as const, + s => `${s}h` as const, + s => `${s}i` as const, + s => `${s}j` as const, + s => `${s}k` as const, + s => `${s}l` as const, + s => `${s}m` as const, + s => `${s}n` as const, + s => `${s}o` as const, + s => `${s}p` as const, + s => `${s}q` as const, + s => `${s}r` as const, + ); + const _type17: Eq<typeof pipeAlphabet.infer, "abcdefghijklmnopqr"> = true; + expect(pipeAlphabet("a")).toEqual("abcdefghijklmnopqr"); +}); + +it("uses pipe for consecutive types", () => { + const Bar = type({ bar: "number" }); + const T = type({ foo: "string" }).pipe(Bar); + const _type18: Eq< + typeof T.t, + { + foo: string; + bar: number; + } + > = true; + const Expected = type({ foo: "string", bar: "number" }); + expect(T.json).toEqual(Expected.json); + expect(T({ foo: "ok", bar: 1 })).toEqual({ foo: "ok", bar: 1 }); +}); + +it("disjoint", () => { + expect(() => type("number>5").pipe(type("number<3"))).toThrow("numeric range intersection is unsatisfiable"); +}); + +it("extract in/out at path", () => { + const T = type({ + foo: type("number").pipe(n => `${n}`, type.string), + }); + + const _type19: Eq<typeof T.in.t, { foo: number }> = true; + expect(T.in.expression).toEqual("{ foo: number }"); + + const _type20: Eq<typeof T.out.t, { foo: string }> = true; + expect(T.out.expression).toEqual("{ foo: string }"); +}); + +it("uses pipe for many consecutive types", () => { + const T = type({ a: "1" }).pipe(type({ b: "1" }), type({ c: "1" }), type({ d: "1" })); + const _type21: Eq< + typeof T, + Type<{ + a: 1; + b: 1; + c: 1; + d: 1; + }> + > = true; + const Expected = type({ a: "1", b: "1", c: "1", d: "1" }); + expect(T.json).toEqual(Expected.json); + expect(T({ a: 1, b: 1, c: 1, d: 1 })).toEqual({ a: 1, b: 1, c: 1, d: 1 }); +}); + +it("two morphs", () => { + const inefficientStringIsEmpty = type("string").pipe( + s => s.length, + length => length === 0, + ); + + const _type22: Eq<typeof inefficientStringIsEmpty.t, (In: string) => Out<boolean>> = true; + expect(inefficientStringIsEmpty("")).toEqual(true); + expect(inefficientStringIsEmpty("foo")).toEqual(false); + expect(inefficientStringIsEmpty(0).toString()).toEqual("must be a string (was a number)"); +}); + +it("any as out", () => { + const T = type("string", "=>", s => s as any); + const _type23: Eq<typeof T.in.infer, string> = true; + // https://github.com/arktypeio/arktype/issues/1023 + // const _type24: Eq<typeof T.infer, any> = true; +}); + +it("never as out", () => { + const T = type("string", "=>", s => s as never); + const _type25: Eq<typeof T.in.infer, string> = true; + const _type26: Eq<typeof T.infer, never> = true; +}); + +it("return error", () => { + const divide100By = type("number", "=>", (n, ctx) => (n !== 0 ? 100 / n : ctx.error("non-zero"))); + const _type27: Eq<typeof divide100By.t, (In: number) => Out<number>> = true; + expect(divide100By(5)).toEqual(20); + expect(divide100By(0).toString()).toContain("must be non-zero"); +}); + +it("at path", () => { + const T = type({ a: ["string", "=>", data => data.length] }); + const _type28: Eq<typeof T.t, { a: (In: string) => Out<number> }> = true; + + const input = { a: "four" }; + + const out = T(input); + + const _type29: Eq<typeof out, { a: number } | type.errors> = true; + expect(out).toEqual({ a: 4 }); +}); + +it("doesn't pipe on error", () => { + const A = type({ a: "number" }).pipe(function addOne(o) { + return o.a + 1; + }); + + const B = type({ a: "string" }, "=>", function appendExclamation(o) { + return `${o.a}!`; + }); + + const T = B.or(A); + + const _type30: Eq<typeof T.t, ((In: { a: string }) => Out<string>) | ((In: { a: number }) => Out<number>)> = true; + + expect(T({ a: 2 })).toEqual(3); +}); + +it("in array", () => { + const types = scope({ + lengthOfString: ["string", "=>", data => data.length], + mapToLengths: "lengthOfString[]", + }).export(); + const _type31: Eq<typeof types.mapToLengths.t, ((In: string) => Out<number>)[]> = true; + const out = types.mapToLengths(["1", "22", "333"]); + const _type32: Eq<typeof out, number[] | type.errors> = true; + expect(out).toEqual([1, 2, 3]); +}); + +it("object to string", () => { + const T = type([{ a: "string" }, "=>", data => JSON.stringify(data)]); + const out = T({ a: "foo" }); + const _type33: Eq<typeof out, string | type.errors> = true; + expect(out).toEqual('{"a":"foo"}'); +}); + +it(".out inferred based on validatedOut", () => { + const Unvalidated = type("string").pipe(s => s.length); + + const _type34: Eq<typeof Unvalidated.infer, number> = true; + // .out won't be known at runtime + const _type35: Eq<typeof Unvalidated.out, Type<unknown>> = true; + + const validated = Unvalidated.pipe(type("number")); + // now that the output is a validated, type, out can be used standalone + const _type36: Eq<typeof validated.out, Type<number>> = true; +}); + +it("intersection", () => { + const $ = scope({ + b: "3.14", + a: [ + "number", + "=>", + function stringifyNumberMorph(data) { + return `${data}`; + }, + ], + aAndB: () => $.type("a&b"), + bAndA: () => $.type("b&a"), + }); + const types = $.export(); + + const _type37: Eq<typeof types.aAndB.t, (In: 3.14) => Out<string>> = true; + expect(types.aAndB(3.14)).toBe("3.14"); + const _type38: Eq<typeof types.bAndA, typeof types.aAndB> = true; + expect(types.bAndA(3.14)).toBe("3.14"); +}); + +it("object intersection", () => { + const $ = scope({ + a: [ + { a: "1" }, + "=>", + // as of TS 5.8, removing data's explicit annotation leads to + // the output being inferred as `never` + function _pipeScopedObjectIntersection(data: { a: 1 }) { + return `${data}`; + }, + ], + b: { b: "2" }, + c: "a&b", + }); + const types = $.export(); + + expect(types.c({ a: 1, b: 2 })).toBe("[object Object]"); +}); + +it("union", () => { + const types = scope({ + a: [ + "number", + "=>", + function _stringifyNumberUnionPipe(data) { + return `${data}`; + }, + ], + b: "boolean", + aOrB: "a|b", + bOrA: "b|a", + }).export(); + const _type39: Eq<typeof types.aOrB.t, boolean | ((In: number) => Out<string>)> = true; + + expect(types.aOrB(2)).toBe("2"); + const _type40: Eq<typeof types.bOrA, typeof types.aOrB> = true; + expect(types.bOrA(true)).toBe(true); +}); + +it("union with output", () => { + const T = type("number|string.numeric.parse"); + const _type41: Eq<typeof T.infer, number> = true; + const _type42: Eq<typeof T.inferIn, string | number> = true; +}); + +it("deep union", () => { + const types = scope({ + a: { + a: [ + "number>0", + "=>", + function _stringifyNumberUnionPipeDeep(data) { + return `${data}`; + }, + ], + }, + b: { a: "Function" }, + c: "a|b", + }).export(); + // biome-ignore lint/complexity/noBannedTypes: Function type in union assertion test + const _type43: Eq<typeof types.c.t, { a: (In: number) => Out<string> } | { a: Function }> = true; + + expect(types.c({ a: 2 })).toEqual({ a: "2" }); + expect(types.c({ a() {} })).toHaveProperty("a"); +}); + +it("chained reference", () => { + const $ = scope({ + a: type("string").pipe(function stringToLength(s) { + return s.length; + }), + b: () => + $.type("a").pipe(function isZeroLength(n) { + return n === 0; + }), + }); + const types = $.export(); + const _type44: Eq<typeof types.b.t, (In: string) => Out<boolean>> = true; + + expect(types.b("")).toBe(true); + expect(types.b("x")).toBe(false); +}); + +it("chained nested", () => { + const $ = scope({ + a: type("string").pipe(function chainedNestedToLength(s) { + return s.length; + }), + b: () => + $.type({ a: "a" }).pipe(function chainedNestedGetA({ a }) { + return a === 0; + }), + }); + + const types = $.export(); + const _type45: Eq<typeof types.b.t, (In: { a: string }) => Out<boolean>> = true; + expect(types.b({ a: "" })).toBe(true); +}); + +it("directly nested", () => { + const A = type("string", "=>", function _directlyNestedStringToLength(s) { + return s.length; + }); + const T = type( + { + // doesn't work with a nested tuple expression here due to a TS limitation + A, + }, + "=>", + function _directlyNestedRoot({ A }) { + return A === 0; + }, + ); + const _type46: Eq<typeof T.t, (In: { A: string }) => Out<boolean>> = true; + expect(T({ A: "" })).toBe(true); +}); + +it("discriminable tuple union", () => { + const $ = scope({ + a: () => + $.type(["string"]).pipe(function _discriminableTupleUnionPipe(s) { + return [...s, "!"]; + }), + b: ["number"], + c: () => $.type("a|b"), + }); + const types = $.export(); + + const _type47: Eq<typeof types.c.t, [number] | ((In: [string]) => Out<string[]>)> = true; + + expect(types.c(["x"])).toEqual(["x", "!"]); + expect(types.c([1])).toEqual([1]); +}); + +it("ArkTypeError not included in return", () => { + const ParsedInt = type([ + "string", + "=>", + (s, ctx) => { + const result = Number.parseInt(s, 10); + if (Number.isNaN(result)) return ctx.error("an integer string"); + + return result; + }, + ]); + const _type48: Eq<typeof ParsedInt.t, (In: string) => Out<number>> = true; + expect(ParsedInt("5")).toEqual(5); + expect(ParsedInt("five").toString()).toContain("must be an integer string"); +}); + +it("nullable return", () => { + const toNullableNumber = type(["string", "=>", s => s.length || null]); + const _type49: Eq<typeof toNullableNumber.t, (In: string) => Out<number | null>> = true; +}); + +it("undefinable return", () => { + const toUndefinableNumber = type(["string", "=>", s => s.length || undefined]); + const _type50: Eq<typeof toUndefinableNumber.t, (In: string) => Out<number | undefined>> = true; +}); + +it("null or undefined return", () => { + const toMaybeNumber = type(["string", "=>", s => (s.length === 0 ? undefined : s.length === 1 ? null : s.length)]); + const _type51: Eq<typeof toMaybeNumber.t, (In: string) => Out<number | null | undefined>> = true; +}); + +it("deep intersection", () => { + const types = scope({ + a: { + a: [ + "number>0", + "=>", + function _deepIntersectionPipePlusOne(data) { + return data + 1; + }, + ], + }, + b: { a: "1" }, + c: "a&b", + }).export(); + const _type52: Eq<typeof types.c.t, { a: (In: 1) => Out<number> }> = true; + + expect(types.c({ a: 1 })).toEqual({ a: 2 }); +}); + +it("morph intersection", () => { + expect(() => + scope({ + a: ["string", "=>", data => `${data}`], + b: ["string", "=>", data => `${data}!!!`], + c: "a&b", + }).export(), + ).toThrow(); +}); + +it("indiscriminable union", () => { + expect(() => { + scope({ + a: ["/.*/", "=>", s => s.trim()], + b: "string", + c: "a|b", + }).export(); + }).toThrow(); +}); + +it("deep morph intersection", () => { + expect(() => { + scope({ + a: { a: ["number", "=>", data => `${data}`] }, + b: { a: ["number", "=>", data => `${data}!!!`] }, + c: "a&b", + }).export(); + }).toThrow(); +}); + +it("deep indiscriminable", () => { + const $ = scope({ + a: { foo: ["string", "=>", s => s.trim()] }, + b: { foo: "symbol" }, + c: { bar: "symbol" }, + }); + + // this is fine as a | b can be discriminated via foo + const T = $.type("a|b"); + const _type53: Eq<typeof T.t, { foo: (In: string) => Out<string> } | { foo: symbol }> = true; + + expect(() => $.type("a|c")).toThrow(); +}); + +it("array double intersection", () => { + expect(() => { + scope({ + a: { a: ["number>0", "=>", data => data + 1] }, + b: { a: ["number>0", "=>", data => data + 2] }, + c: "a[]&b[]", + }).export(); + }).toThrow(); +}); + +it("undiscriminated morph at path", () => { + expect(() => { + scope({ + a: { a: ["string", "=>", s => s.trim()] }, + b: { b: "bigint" }, + c: { key: "a|b" }, + }).export(); + }).toThrow(); +}); + +it("helper morph intersection", () => { + expect(() => + type("string") + .pipe(s => s.length) + .and(type("string").pipe(s => s.length)), + ).toThrow(); +}); + +it("union helper undiscriminated", () => { + expect(() => + type("string") + .pipe(s => s.length) + .or("'foo'"), + ).toThrow(); +}); + +it("allows undiscriminated union if morphs are equal", () => { + const T = type({ foo: "1" }) + .or({ bar: "1" }) + .pipe(function getObjectValues(o) { + return Object.values(o); + }); + + const _type54: Eq<typeof T.t, (In: { foo: 1 } | { bar: 1 }) => Out<1[]>> = true; + + expect(T({ foo: 1 })).toEqual([1]); + expect(T({ bar: 1 })).toEqual([1]); + expect(T({ baz: 2 }).toString()).toContain("bar must be 1"); +}); +it("allows undiscriminated union if morphs at path are equal", () => { + const T = type({ l: "1", n: "string.numeric.parse" }, "|", { + r: "1", + n: "string.numeric.parse", + }); + + expect(T({ l: 1, n: "234" })).toEqual({ l: 1, n: 234 }); + expect(T({ r: 1, n: "234" })).toEqual({ r: 1, n: 234 }); + expect(T({ l: 1, r: 1, n: "234" })).toEqual({ l: 1, r: 1, n: 234 }); + expect(T({ n: "234" }).toString()).toContain("l must be 1 (was missing)"); + expect(T({ n: "234" }).toString()).toContain("r must be 1 (was missing)"); +}); +it("fails on indiscriminable morph in nested union", () => { + const indiscriminable = () => + type({ + foo: "boolean | string.date.parse", + }).or({ + foo: "boolean | string.json.parse", + }); + + expect(indiscriminable).toThrow("indeterminate"); +}); + +it("multiple chained pipes", () => { + const T = type("string.trim").to("string.lower"); + + expect(T("Success")).toEqual("success"); + expect(T("success")).toEqual("success"); + expect(T("SUCCESS ")).toEqual("success"); + expect(T("success ")).toEqual("success"); +}); + +// https://github.com/arktypeio/arktype/issues/1144 +it("multiple chained pipes with literal output", () => { + const Base = type("string.trim").to("string.lower"); + + const T = Base.to("'success'"); + + const _type55: Eq<typeof T.t, (In: string) => To<"success">> = true; + + expect(T("Success")).toEqual("success"); + expect(T("success")).toEqual("success"); + expect(T("SUCCESS ")).toEqual("success"); + expect(T("success ")).toEqual("success"); +}); + +const appendLengthMorph = (s: string) => `${s}${s.length}`; + +// https://discord.com/channels/957797212103016458/1291014543635517542 +it("repeated Type pipe", () => { + const appendLength = type("string", "=>", appendLengthMorph); + const appendLengths = type("string").pipe(appendLength, appendLength); + + expect(appendLengths("a")).toEqual("a12"); +}); + +// https://discord.com/channels/957797212103016458/1291014543635517542 +it("repeated Type pipe with intermediate morph", () => { + const appendLength = type("string", "=>", appendLengthMorph); + + const appendSeparatorMorph = (s: string) => `${s}|`; + + const appendSeparatedLengths = type("string").pipe( + appendLength, + appendLength, + appendSeparatorMorph, + appendLength, + appendLength, + ); + + expect(appendSeparatedLengths("a")).toEqual("a12|45"); +}); + +it("doesn't lose input prop morphs", () => { + const T = type({ + foo: type("string").pipe(s => s.length), + }) + .pipe(o => o) + .to({ + foo: "number", + }); + expect(T({ foo: "bar" })).toEqual({ foo: 3 }); + + const types = scope({ + From: { a: ["1", "=>", () => 2] }, + Morph: ["From", "=>", e => e], + To: { a: "2" }, + }).export(); + const U = types.Morph.pipe(e => e, types.To); + const out = U({ a: 1 }); + const _type56: Eq< + typeof out, + | ArkErrors + | { + a: 2; + } + > = true; + expect(out).toEqual({ a: 2 }); +}); + +// https://github.com/arktypeio/arktype/issues/1185 +it("pipe doesn't run on rejected descendant prop", () => { + let callCount = 0; + const T = type({ + key: "string", + }).pipe(v => { + callCount++; + return v; + }); + + const out = T({}); + + expect(out.toString()).toEqual("key must be a string (was missing)"); + expect(callCount).toEqual(0); +}); + +it("to tuple expression", () => { + const T = type(["string.json.parse", "|>", { name: "string" }]); + + const Expected = type("string.json.parse").to({ name: "string" }); + + const _type57: Eq<typeof T, typeof Expected> = true; + expect(T('{"name":"Ada"}')).toEqual({ name: "Ada" }); +}); + +it("to args expression", () => { + const T = type("string.json.parse", "|>", { name: "string" }); + + const Expected = type("string.json.parse").to({ name: "string" }); + + const _type58: Eq<typeof T, typeof Expected> = true; + expect(T('{"name":"Ada"}')).toEqual({ name: "Ada" }); +}); + +it("infers distributed pipes", () => { + const T = type("string.numeric.parse | number").to("number > 0"); + expect(T("5")).toBe(5); +}); + +it("extracted from cyclic type", () => { + const T = type({ + morphed: "string.numeric.parse", + "nested?": "this", + }); + + const t = T.assert({ morphed: "5" }); + + expect(t).toEqual({ morphed: 5 }); + const _type59: Eq<typeof t.morphed, number> = true; + const nestedMorphed = t.nested?.morphed; + const _type60: Eq<typeof nestedMorphed, number | undefined> = true; +}); + +it("extract in/out preserves undeclared rejection", () => { + const T = type({ + "+": "reject", + foo: "true", + }); + + expect(T.in({ foo: true, bar: 1 }).toString()).toContain("bar must be removed"); +}); + +it("complex morphs are applied on correct path", () => { + let c: null | 1; + + const M = type({ + list: type("object") + .pipe(e => e) + .pipe( + type({ + z: type("unknown").pipe(() => c), + }), + ) + .array(), + _: ["unknown", ":", () => true], + }); + + c = null; + expect(() => + M.assert({ + list: [{ z: "" }, { z: "" }], + }), + ).toThrow("_ must be valid according to an anonymous predicate (was missing)"); + + c = 1; + expect(() => + M.assert({ + list: [{ z: "" }, { z: "" }], + }), + ).toThrow("_ must be valid according to an anonymous predicate (was missing)"); +}); + +// https://github.com/arktypeio/arktype/pull/1464 +it("branched optimistic pipe union", () => { + class TypeA { + type = "typeA"; + } + + class TypeB { + type = "typeB"; + } + + const typeA = new TypeA(); + + const Thing = type.or( + type.instanceOf(TypeB), + type.string.pipe(_value => new TypeB()), + type.instanceOf(TypeA).pipe(_value => new TypeB()), + ); + + const out = Thing.assert(typeA); + expect(out).toBeInstanceOf(TypeB); +}); + +// https://github.com/arktypeio/arktype/pull/1464 +it("complex pipes", () => { + const inputData = [ + { + OuterKey: [ + { + MiddleKey: [ + { + InnerKey: [], + }, + ], + }, + ], + }, + ]; + + const genericSchema = type("Record<string, unknown>[]") + .pipe.try(arr => + arr.map(item => { + const [kind, value] = Object.entries(item)[0]; + return { kind, value }; + }), + ) + .pipe( + type({ kind: "string", value: "unknown" }) + .pipe(item => ({ kind: item.kind, value: item.value })) + .array(), + arr => + arr.reduce<Record<string, { value: unknown }>>((acc, { kind, value }) => { + acc[kind] = { value }; + return acc; + }, {}), + type({ + OuterKey: { + value: type({ + MiddleKey: type({ InnerKey: type("object") }) + .array() + .pipe(v => v[0]), + }).array(), + }, + }), + ); + + const result = genericSchema(inputData); + expect(result).toEqual({ + OuterKey: { + value: [ + { + MiddleKey: { InnerKey: [] }, + }, + ], + }, + }); +}); + +// https://github.com/arktypeio/arktype/pull/1464 +it("nested pipes", () => { + const parseFirstElementToNumber = type("string[]") + .pipe(v => v[0]) + .to("string.numeric.parse"); + + const extractAndParseFirstElement = type({ + Value: parseFirstElementToNumber, + }) + .array() + .pipe(v => v[0]); + + const Item = type({ + SubItem: extractAndParseFirstElement, + Meta: {}, + }); + + const T = type({ + Item: Item.array(), + }); + + const data = { + Item: [ + { + SubItem: [ + { + Value: ["0"], + }, + ], + }, + { + SubItem: [ + { + Value: ["0"], + }, + ], + }, + ], + }; + + const result = T(data); + + expect(result.toString()).toEqual(`Item[0].Meta must be an object (was missing) +Item[1].Meta must be an object (was missing)`); +}); diff --git a/packages/omptype/test/ark/range.test.ts b/packages/omptype/test/ark/range.test.ts new file mode 100644 index 000000000..1285a6dec --- /dev/null +++ b/packages/omptype/test/ark/range.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "bun:test"; +import { type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("string expressions", () => { + it(">", () => { + const T = type("number>0"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + }); + + it("<", () => { + const T = type("number<10"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + const Expected = { + domain: "number", + max: { rule: 10, exclusive: true }, + }; + expect(Expected).toBeDefined(); + }); + + it("<=", () => { + const T = type("number<=-49"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + const Expected = { + domain: "number", + max: { rule: -49, exclusive: false }, + }; + expect(Expected).toBeDefined(); + }); + + it("==", () => { + const T = type("number==3211993"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + const Expected = { unit: 3211993 }; + expect(Expected).toBeDefined(); + }); + + it("== length", () => { + const T = type({ code: "string==6" }); + + expect(T({ code: "123456" })).toEqual({ code: "123456" }); + expect(String(T({ code: "foo" }))).toBe("code must be at least length 6 (was 3)"); + }); + + it("<,<=", () => { + const T = type("-5<number<=5"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + const Expected = { + domain: "number", + min: { rule: -5, exclusive: true }, + max: 5, + }; + expect(Expected).toBeDefined(); + }); + + it("<=,<", () => { + const T = type("-3.23<=number<4.654"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + const Expected = { + domain: "number", + min: { rule: -3.23 }, + max: { rule: 4.654, exclusive: true }, + }; + expect(Expected).toBeDefined(); + }); + + it("whitespace following comparator", () => { + const T = type("number > 3"); + const _type: Eq<typeof T.infer, number> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<number, {}>> = true; + const Expected = { + domain: "number", + min: { rule: 3, exclusive: true }, + }; + expect(Expected).toBeDefined(); + }); + + it("single Date", () => { + const T = type("Date<d'2023/1/12'"); + const _type: Eq<typeof T.infer, Date> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<Date, {}>> = true; + }); + + it("Date equality", () => { + const T = type("Date==d'2020-1-1'"); + const _type: Eq<typeof T.infer, Date> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<Date, {}>> = true; + expect(T.allows(new Date("2020/01/01"))).toEqual(true); + expect(T.allows(new Date("2020/01/02"))).toEqual(false); + }); + + it("double Date", () => { + const T = type("d'2001/10/10'< Date < d'2005/10/10'"); + const _type: Eq<typeof T.infer, Date> = true; + const _t: Eq<typeof T.t, Date> = true; + expect(T.allows(new Date("2003/10/10"))).toEqual(true); + expect(T.allows(new Date("2001/10/10"))).toEqual(false); + expect(T.allows(new Date("2005/10/10"))).toEqual(false); + }); + + it("dynamic Date", () => { + const now = new Date(); + const T = type(`d'2000'< Date <=d'${now.toISOString()}'`); + const _type: Eq<typeof T.infer, Date> = true; + // biome-ignore lint/complexity/noBannedTypes: Type second generic parameter test + const _schema: Eq<typeof T, Type<Date, {}>> = true; + expect(T.allows(new Date(now.valueOf() - 1000))).toEqual(true); + expect(T.allows(now)).toEqual(true); + expect(T.allows(new Date(now.valueOf() + 1000))).toEqual(false); + }); + + it("exclusive length normalized", () => { + const T = type("string > 0"); + const Expected = type("string >= 1"); + expect(T.json).toEqual(Expected.json); + }); + + it("trivially satisfied length normalized", () => { + const T = type("string >= 0"); + const Expected = type("string"); + expect(T.json).toEqual(Expected.json); + }); + + it("invalid left comparator", () => { + expect(() => type("3>number<5")).toThrow(); + }); + + it("invalid right double-bound comparator", () => { + expect(() => type("3<number==5")).toThrow(); + }); + + it("unpaired left", () => { + expect(() => type("3<number")).toThrow(); + }); + + it("unpaired left group", () => { + expect(() => type("(-1<=number)")).toThrow(); + }); + + it("double left", () => { + expect(() => type("3<5<8")).toThrow(); + }); + + it("empty range", () => { + expect(() => type("3<=number<2")).toThrow('numeric range is unsatisfiable in "3<=number<2"'); + }); + + it.todo("double right bound"); + + it("negative-length", () => { + expect(() => type("string < 0")).toThrow(); + }); + + it("non-integer length", () => { + expect(() => type("string >= 2.5")).toThrow(); + }); + + it("non-narrowed bounds", () => { + const a = 5 as number; + const b = 7 as number; + const T = type(`${a}<number<${b}`); + const _type: Eq<typeof T.infer, number> = true; + }); + + it("fails at runtime on malformed right", () => { + expect(() => type("number<07")).toThrow(); + }); + + it("fails at runtime on malformed lower", () => { + expect(() => type("3.0<number<5")).toThrow(); + }); + + it("number", () => { + const T = type("number==-3.14159"); + const _type: Eq<typeof T.infer, number> = true; + }); + + it("string", () => { + const T = type("string<=5"); + const _type: Eq<typeof T.infer, string> = true; + }); + + it("array", () => { + const T = type("87<=boolean[]<89"); + const _type: Eq<typeof T.infer, boolean[]> = true; + }); + + it("multiple bound kinds", () => { + expect(() => type("(number | boolean[])>0")).toThrow(); + }); + + it("unknown", () => { + expect(() => type("unknown<10")).toThrow(); + }); + + it("unboundable", () => { + expect(() => type("object>10")).toThrow(); + }); + + it("morph", () => { + expect(() => type("string.trim > 2")).toThrow('cannot bound morph in "string.trim > 2"'); + }); + + it("same bound kind union", () => { + const T = type("1<(number[]|object[])<10"); + const _type: Eq<typeof T.infer, number[] | object[]> = true; + const Expected = type("1<number[]<10 | 1<object[]<10"); + expect(T.json).toEqual(Expected.json); + }); + + it("number with right Date bound", () => { + expect(() => type("number<d'2001/01/01'")).toThrow(); + }); + + it("number with left Date bound", () => { + expect(() => type("d'2001/01/01'<number<2")).toThrow(); + }); +}); + +describe("chained", () => { + it("atLeast", () => { + const T = type("number").atLeast(5); + const Expected = type("number>=5"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid min operand", () => { + expect(() => type("string").atLeast(5)).toThrow(); + }); + + it("moreThan", () => { + const T = type("number").moreThan(5); + const Expected = type("number>5"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("atMost", () => { + const T = type("number").atMost(10); + const Expected = type("number<=10"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("lessThan", () => { + const T = type("number").lessThan(10); + const Expected = type("number<10"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid max operand", () => { + expect(() => type("string").lessThan(5)).toThrow(); + }); + + it("atLeastLength", () => { + const T = type("string").atLeastLength(5); + const Expected = type("string>=5"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("moreThanLength", () => { + const T = type("string[]").moreThanLength(5); + const Expected = type("string[]>5"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid minLength operand", () => { + expect(() => type("bigint").atLeastLength(5)).toThrow(); + }); + + it("atMostLength", () => { + const T = type("string").atMostLength(10); + const Expected = type("string<=10"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("lessThanLength", () => { + const T = type("string[]").lessThanLength(10); + const Expected = type("string[]<10"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid maxLength operand", () => { + expect(() => type("null").lessThanLength(5)).toThrow(); + }); + + it("atOrAfter", () => { + const T = type("Date").atOrAfter(new Date("2022-01-01")); + // widen the input to a string so both are non-narrowed + const Expected = type(`Date>=d'${"2022-01-01" as string}'`); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("laterThan", () => { + const T = type("Date").laterThan(new Date("2022-01-01")); + const Expected = type(`Date>d'${"2022-01-01" as string}'`); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid after operand", () => { + expect(() => type("false").laterThan(new Date())).toThrow(); + }); + + it("atOrBefore", () => { + const T = type("Date").atOrBefore(5); + const Expected = type("Date<=5"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("earlierThan", () => { + const T = type("Date").earlierThan(5); + const Expected = type("Date<5"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid before operand", () => { + expect(() => type("unknown").atOrBefore(new Date())).toThrow(); + }); +}); + +it("unit overlap", () => { + const five = type("5 <= number < 10").and("0 < number <= 5"); + expect(five.allows(5)).toBe(true); +}); diff --git a/packages/omptype/test/ark/realWorld.test.ts b/packages/omptype/test/ark/realWorld.test.ts new file mode 100644 index 000000000..999d66c95 --- /dev/null +++ b/packages/omptype/test/ark/realWorld.test.ts @@ -0,0 +1,1321 @@ +import { expect, it } from "bun:test"; +import { AssertionError } from "node:assert"; +import { type ArkErrors, type Module, type StandardSchemaV1, scope, type Type, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +type Out<t> = t; +type To<t> = t; + +declare class TimeStub { + declare readonly isoString: string; + + private constructor(); + + declare static from: (isoString: string) => TimeStub; + + declare static fromDate: (date: Date) => TimeStub; + + declare toDate: () => Date; + + declare toString: () => string; +} + +// https://github.com/arktypeio/arktype/issues/915 +it("time stub w/ private constructor", () => { + // TimeStub is just declared at a type-level since --experimental-strip-types + // doesn't yet support private constructors + + const MockTimeStub = class TimeStub {}; + + const types = scope({ + timeStub: ["instanceof", MockTimeStub] as type.cast<TimeStub>, + account: "clientDocument&accountData", + clientDocument: { + "id?": "string", + "coll?": "string", + "ts?": "timeStub", + "ttl?": "timeStub", + }, + accountData: { + user: "user|timeStub", + provider: "provider", + providerUserId: "string", + }, + user: { + name: "string", + "accounts?": "account[]", + }, + provider: "'GitHub'|'Google'", + }).export(); + + const stub = new MockTimeStub(); + const valid = { + user: stub, + provider: "GitHub", + providerUserId: "123", + ts: stub, + }; + expect(types.account(valid)).toEqual(valid); + expect(types.account({ ...valid, provider: "Other" }).toString()).toEqual( + 'provider must be "GitHub" or "Google" (was "Other")', + ); +}); + +it("nested bound traversal", () => { + // https://github.com/arktypeio/arktype/issues/898 + const User = type({ + name: "string", + email: "string.email", + tags: "(string>=2)[]>=3", + score: "number.integer>=0", + }); + + const out = User({ + name: "Ok", + email: "", + tags: ["AB", "B"], + score: 0, + }); + + expect(out.toString()).toEqual(`email must be an email address (was "") +tags must be at least length 3 (was 2)`); +}); + +it("multiple refinement errors", () => { + const nospacePattern = /^\S*$/; + + const Schema = type({ + name: "string", + email: "string.email", + tags: "(string>=2)[]>=3", + score: "number.integer>=0", + "date?": "Date", + "nospace?": nospacePattern, + extra: "string|null", + }); + + const data = { + name: "Ok", + email: "", + tags: ["AB", "B"], + score: -1, + date: undefined, + nospace: "One space", + }; + + const out = Schema(data); + + expect(out.toString()).toEqual(`email must be an email address (was "") +tags must be at least length 3 (was 2) +score must be non-negative (was -1) +date must be a Date (was undefined) +nospace must be a string matching /^\\S*$/ (was "One space") +extra must be a string or null (was missing)`); +}); + +it("discrimination false negative", () => { + // https://github.com/arktypeio/arktype/issues/910 + const badScope = scope({ + a: { + x: "'x1'", + y: "'y1'", + z: "string", + }, + b: { + x: "'x1'", + y: "'y2'", + z: "number", + }, + c: { + x: "'x2'", + y: "'y3'", + z: "string", + }, + union: "a | b | c", + }).export(); + + const badType = badScope.union; + + type Test = typeof badType.infer; + + const value: Test = { + x: "x2", + y: "y3", + z: "", + }; // no type error + + const out = badType(value); // matches scope union item 'c'; should not fail + expect(out).toEqual(value); +}); +it("morph path", () => { + // https://github.com/arktypeio/arktype/issues/754 + const withMorph = type({ + key: type("string").pipe(type("3<=string<=4"), s => s.trim()), + }); + + const outWithMorph = withMorph({ + key: " This is too long ", + }); + + expect(outWithMorph.toString()).toEqual("key must be at most length 4 (was 20)"); + + const withoutMorph = type({ + key: type("3<=string<=4"), + }); + + const outWithoutMorph = withoutMorph({ + key: " This is too long ", + }); + + expect(outWithoutMorph.toString()).toEqual("key must be at most length 4 (was 20)"); +}); + +it("cross scope reference", () => { + // https://github.com/arktypeio/arktype/issues/700 + const A = type({ + required: "boolean", + }); + + const B = scope({ A }).type({ + a: "A", + }); + + const C = scope({ + B, + }).type({ + b: "B", + }); + + const _crossC: Eq<typeof C.t, { b: { a: { required: boolean } } }> = true; + + const _crossScope: Eq<typeof C.$.t, { B: { a: { required: boolean } } }> = true; + + const valid = { b: { a: { required: true } } }; + expect(C(valid)).toEqual(valid); + expect(C({ b: { a: {} } }).toString()).toEqual("b.a.required must be boolean (was missing)"); +}); + +// https://github.com/arktypeio/arktype/issues/947 +it("chained inline type expression inference", () => { + const A = type({ + action: "'a' | 'b'", + }).or({ + action: "'c'", + }); + + const Referenced = type({ + someField: "string", + }).and(A); + + const _referenced: Eq< + typeof Referenced.infer, + { someField: string; action: "a" | "b" } | { someField: string; action: "c" } + > = true; + + const Inlined = type({ + someField: "string", + }).and( + type({ + action: "'a' | 'b'", + }).or({ + action: "'c'", + }), + ); + + const _inlined: Eq<typeof Inlined, typeof Referenced> = true; +}); + +// https://discord.com/channels/957797212103016458/1242116299547476100 +it("infers morphs at nested paths", () => { + const parseBigint = type("string", "=>", (s, ctx) => { + try { + return BigInt(s); + } catch { + return ctx.error("a valid number"); + } + }); + + const Test = type({ + group: { + nested: { + value: parseBigint, + }, + }, + }); + + const out = Test({ group: { nested: { value: "5" } } }); + const _nestedMorph: Eq<typeof Test.infer.group.nested.value, bigint> = true; + expect(out).toEqual({ group: { nested: { value: 5n } } }); +}); + +// https://discord.com/channels/957797212103016458/957804102685982740/1242221022380556400 +it("nested pipe to validated output", () => { + const trimString = (s: string) => s.trim(); + + const validatedTrimString = type("string").pipe(trimString, type("1<=string<=3")); + + const CreatePatientInput = type({ + "patient_id?": "string|null", + "first_name?": validatedTrimString.or("null"), + "middle_name?": "string|null", + "last_name?": "string|null", + }); + + const _validatedOutput: Eq<typeof CreatePatientInput.t.first_name, ((In: string) => To<string>) | null | undefined> = + true; + + expect(CreatePatientInput({ first_name: " Bob " })).toEqual({ + first_name: "Bob", + }); + expect(CreatePatientInput({ first_name: " John " }).toString()).toEqual( + "first_name must be at most length 3 (was 4)", + ); + + expect(CreatePatientInput({ first_name: 5 }).toString()).toEqual( + "first_name must be a string or null (was a number)", + ); +}); + +// https://github.com/arktypeio/arktype/issues/968 +it("handles consecutive pipes", () => { + const MyAssets = scope({ + Asset: { + token: "string", + amount: type("string").pipe((s, ctx) => { + try { + return BigInt(s); + } catch { + return ctx.error("a valid non-decimal number"); + } + }), + }, + Assets: { + assets: "Asset[]>=1", + }, + }) + .export() + .Assets.pipe(o => { + const assets = o.assets.reduce<Record<string, bigint>>((acc, asset) => { + acc[asset.token] = asset.amount; + return acc; + }, {}); + return { ...o, assets }; + }); + + const out = MyAssets({ assets: [{ token: "a", amount: "1" }] }); + + expect(out).toEqual({ assets: { a: 1n } }); +}); + +// https://discord.com/channels/957797212103016458/957804102685982740/1243850690644934677 +it("more chained pipes/narrows", () => { + const Amount = type("string", ":", (s, ctx) => Number.isInteger(Number(s)) || ctx.reject("number")) + .pipe((s, ctx) => { + try { + return BigInt(s); + } catch { + return ctx.error("a non-decimal number"); + } + }) + .narrow(() => true); + + const Token = type("7<string<=120") + .pipe(s => s.toLowerCase()) + .narrow(() => true); + + const $ = scope({ + Asset: { + token: Token, + amount: Amount, + }, + Assets: () => $.type("Asset[]>=1").pipe(assets => assets), + }); + + const types = $.export(); + + const out = types.Assets([{ token: "lovelace", amount: "5000000" }]); + + expect(out).toEqual([{ token: "lovelace", amount: 5000000n }]); +}); + +it("regex index signature", () => { + const test = scope({ + svgPath: /^\.\/([\da-f])+(-([\da-f])+)*\.svg$/, + svgMap: { + "[svgPath]": "string.digits", + }, + }).export(); + const _regexModule: Eq< + typeof test, + Module<{ + svgMap: { [x: string]: string }; + svgPath: string; + }> + > = true; + expect(test.svgMap({ "./f.svg": "123", bar: 5 })).toEqual({ + "./f.svg": "123", + bar: 5, + }); + expect(test.svgMap({ "./f.svg": "123a" }).toString()).toEqual('./f.svg must be only digits 0-9 (was "123a")'); +}); + +it("standalone type from cyclic", () => { + const types = scope({ + JsonSchema: "JsonSchemaArray|JsonSchemaNumber", + JsonSchemaArray: { + items: "JsonSchema", + type: "'array'", + }, + JsonSchemaNumber: { + type: "'number'|'integer'", + }, + }).export(); + + const standalone = types.JsonSchemaArray.describe("standalone"); + + const valid: typeof standalone.infer = { + type: "array", + items: { type: "array", items: { type: "number" } }, + }; + + const out = standalone(valid); + + expect(out).toEqual(valid); + + const failOut = standalone({ + type: "array", + items: { type: "array" }, + }); + + expect(failOut.toString()).toEqual( + 'items.items must be JsonSchema (was missing) or items.type must be "number" or "integer" (was "array")', + ); +}); + +it("more external cyclic scope references", () => { + const $ = scope({ + box: { + "box?": "box", + }, + }); + + const box = $.type("box|null"); + + expect(box({})).toEqual({}); + expect(box(null)).toEqual(null); + expect(box({ box: { box: { box: {} } } })).toEqual({ + box: { box: { box: {} } }, + }); + expect(box({ box: { box: { box: "whoops" } } })?.toString()).toEqual("box.box.box must be an object (was a string)"); +}); + +it("morph with alias child", () => { + const types = scope({ + ArraySchema: { + "items?": "Schema", + }, + Schema: "TypeWithKeywords", + TypeWithKeywords: "ArraySchema", + }).export(); + + const T = types.Schema.pipe(o => JSON.stringify(o)); + + expect(T({ items: {} })).toEqual('{"items":{}}'); + expect(T({ items: null }).toString()).toEqual("items must be an object (was null)"); +}); + +it("terse missing key error", () => { + const types = scope({ + Library: { + sections: "Sections", + }, + Sections: { + "[string]": "Book[]", + }, + Book: { + isbn: "string", + title: "string", + "subtitle?": "string", + authors: "string[]", + publisher: "Publisher", + }, + Publisher: { + id: "string", + name: "string", + }, + }).export(); + + expect(types.Library({}).toString()).toEqual("sections must be Sections (was missing)"); +}); + +it("narrowed quoted description", () => { + const T = type("string") + .narrow(function _narrowedQuoteDescription() { + return true; + }) + .describe('This will "fail"'); + + const _narrowedDescription: Eq<typeof T.t, string> = true; + + expect(T("ok")).toEqual("ok"); + expect(T(5).toString()).toEqual('must be This will "fail" (was a number)'); +}); + +it("extract in of narrowed morph", () => { + const SubSubType = type("string").pipe(s => parseInt(s, 10)); + const SubType = type({ amount: SubSubType }).narrow(() => true); + const MyType = type({ + sub: SubType, + }); + + type MyType = typeof MyType.in.infer; + + const _narrowedInput: Eq<MyType, { sub: { amount: string } }> = true; +}); + +it("narrowed morph", () => { + const T = type("string") + .pipe(s => parseInt(s, 10)) + .narrow(() => true); + + const _narrowedMorph: Eq<typeof T.t, (In: string) => Out<number>> = true; + + const _u = T.pipe( + n => `${n}`, + s => `${s}++` as const, + ); +}); + +it("recursive reference from union", () => { + const $ = scope({ + TypeWithKeywords: "ArraySchema", + Schema: "number|ArraySchema", + ArraySchema: { + "additionalItems?": "Schema", + }, + }); + + const types = $.export(); + const valid = { additionalItems: { additionalItems: 1 } }; + expect(types.ArraySchema(valid)).toEqual(valid); + expect(types.ArraySchema({ additionalItems: "x" }).toString()).toEqual( + "additionalItems must be a number or ArraySchema (was a string)", + ); +}); + +// https://discord.com/channels/957797212103016458/957804102685982740/1254900389346807849 +it("narrows nested morphs", () => { + const parseBigint = type("string").pipe(s => BigInt(s)); + const Fee = type({ amount: parseBigint }).narrow(fee => typeof fee.amount === "bigint"); + + const Claim = type({ + fee: Fee, + }); + + const out = Claim.assert({ fee: { amount: "5" } }); + + expect(out).toEqual({ fee: { amount: 5n } }); +}); + +// https://github.com/arktypeio/arktype/issues/1037 +it("can morph an optional key", () => { + const T = type({ + "optionalKey?": ["string", "=>", x => x.toLowerCase()], + }); + + const _optionalMorph: Eq<typeof T.t, { optionalKey?: (In: string) => Out<string> }> = true; + + expect(T({})).toEqual({}); + + expect(T({ optionalKey: "FOO" })).toEqual({ optionalKey: "foo" }); +}); + +// https://discord.com/channels/957797212103016458/1261621890775126160/1261621890775126160 +it("can narrow output of a piped union", () => { + const parseBigint = (v: string | number) => BigInt(v); + const validatePositiveBigint = (b: bigint) => b > 0n; + + const Amount = type("string|number").pipe(parseBigint).narrow(validatePositiveBigint); + + const _pipedUnion: Eq<typeof Amount.t, (In: string | number) => Out<bigint>> = true; + + expect(Amount("1000")).toEqual(1000n); + expect(Amount("-5").toString()).toEqual("must be valid according to validatePositiveBigint (was -5n)"); +}); + +it("nested 'and' chained from morph on optional", () => { + const validatedTrimString = type("string").pipe(s => s.trim(), type("1<=string<=3")); + + const T = type({ + "first_name?": validatedTrimString.and("unknown"), + }); + + expect(T({ first_name: " ok " })).toEqual({ first_name: "ok" }); + expect(T({ first_name: " toolong " }).toString()).toEqual("first_name must be at most length 3 (was 7)"); +}); + +it("cyclic narrow in scope", () => { + const _root = scope({ + filename: "0<string<255", + file: { + type: "'file'", + name: "filename", + }, + directory: { + type: "'directory'", + name: "filename", + children: [ + "root[]", + ":", + (v, ctx) => { + if (new Set(v.map(f => f.name)).size !== v.length) + return ctx.mustBe("names must be unique in a directory"); + + return true; + }, + ], + }, + root: "file|directory", + }).resolve("root"); +}); + +// https://github.com/arktypeio/arktype/discussions/1080#discussioncomment-10247616 +it("pipe to discriminated morph union", () => { + const ObjSchema = type({ + action: "'order.completed'", + }).or({ + action: `'scheduled'`, + id: "string.integer.parse", + calendarID: "string.integer.parse", + appointmentTypeID: "string.integer.parse", + }); + + const parseJsonToObj = type("string.json.parse").pipe(ObjSchema); + + const out = parseJsonToObj( + JSON.stringify({ + action: "scheduled", + id: "1", + calendarID: "1", + appointmentTypeID: "1", + }), + ); + + const _pipeUnion: Eq< + typeof out, + | ArkErrors + | { action: "order.completed" } + | { + action: "scheduled"; + id: number; + calendarID: number; + appointmentTypeID: number; + } + > = true; + + expect(out).toEqual({ + action: "scheduled", + id: 1, + calendarID: 1, + appointmentTypeID: 1, + }); +}); + +// https://github.com/arktypeio/arktype/discussions/1080#discussioncomment-10247616 +it("pipe to discriminated morph inner union", () => { + const ObjSchema = type({ + action: "'order.completed'", + }).or({ + action: "'scheduled' | 'rescheduled' | 'canceled' | 'changed'", + id: "string.integer.parse", + calendarID: "string.integer.parse", + appointmentTypeID: "string.integer.parse", + }); + + const parseJsonToObj = type("string.json.parse").pipe(ObjSchema); + + const out = parseJsonToObj( + JSON.stringify({ + action: "scheduled", + id: "1", + calendarID: "1", + appointmentTypeID: "1", + }), + ); + + expect(out).toEqual({ + action: "scheduled", + id: 1, + calendarID: 1, + appointmentTypeID: 1, + }); +}); + +// https://discord.com/channels/957797212103016458/957804102685982740/1276840721370054688 +it("directly nested piped type instantiation", () => { + const T = type({ + "test?": type("string").pipe(x => x === "true"), + }); + + const _nestedPipe: Eq<typeof T.t, { test?: (In: string) => Out<boolean> }> = true; +}); + +it("discriminated union error", () => { + const C = type({ city: "string", "+": "reject" }).pipe(o => ({ + ...o, + type: "city", + })); + const N = type({ name: "string", "+": "reject" }).pipe(o => ({ + ...o, + type: "name", + })); + + const T = C.or(N); + + const out = T({ city: "foo", name: "foo" }); + expect(out.toString()).toEqual('name must be removed (was "foo") or city must be removed (was "foo")'); +}); + +it("array intersection with object literal", () => { + const T = type({ name: "string" }).and("string[]"); + + const valid = Object.assign(["x"], { name: "box" }); + expect(T(valid)).toEqual(valid); + expect(T(["x"]).toString()).toEqual("name must be a string (was missing)"); +}); + +it("tuple or morph inference", () => { + const T = type(["string", "string"]).or(["null", "=>", () => undefined]); + + expect(T(["a", "b"])).toEqual(["a", "b"]); + expect(T(null)).toBeUndefined(); +}); + +it("scoped discrimnated union", () => { + const $ = scope({ + TypeWithNoKeywords: { type: "'boolean'|'null'" }, + TypeWithKeywords: "ArraySchema|ObjectSchema", // without both ArraySchema and ObjectSchema there's no error + // "#BaseSchema": "TypeWithNoKeywords|boolean", // errors even with union reversed + "#BaseSchema": "boolean|TypeWithNoKeywords", // without the `boolean` there's no error (even if still union such as `string|TypeWithNoKeywords`) + ArraySchema: { + "additionalItems?": "BaseSchema", // without this reference there's no error + type: "'array'", + }, + // If `ObjectSchema` doesn't have `type` key there's no error + ObjectSchema: { + type: "'object'", + }, + }); + const JsonSchema = $.export(); + + expect( + JsonSchema.TypeWithKeywords({ + type: "array", + additionalItems: { type: "boolean" }, + }), + ).toEqual({ type: "array", additionalItems: { type: "boolean" } }); + expect( + JsonSchema.TypeWithKeywords({ + type: "array", + additionalItems: { + type: "whoops", + }, + }).toString(), + ).toEqual('additionalItems.type must be "boolean" or "null" (was "whoops")'); +}); + +// https://github.com/arktypeio/arktype/issues/1127 +it("keys can overlap with RegExp", () => { + const MaybeEmpty = type("<t>", "t|undefined|null"); + + const ApiSchema = type({ + ref: MaybeEmpty("string"), + service_code: MaybeEmpty("number"), + action: MaybeEmpty("string"), + source: type("string | null"), + lastIndex: type("string | null"), + }); + + const _overlapRegex: Eq< + typeof ApiSchema.t, + { + ref: string | null | undefined; + service_code: number | null | undefined; + action: string | null | undefined; + source: string | null; + lastIndex: string | null; + } + > = true; + + expect( + ApiSchema({ + ref: undefined, + service_code: 42, + action: null, + source: "web", + lastIndex: null, + }), + ).toEqual({ + ref: undefined, + service_code: 42, + action: null, + source: "web", + lastIndex: null, + }); +}); + +it("error on bounded liftArray", () => { + // @ts-expect-error + expect(() => type("2 < Array.liftFrom<string> < 4")).toThrow( + 'cannot bound morph in "2 < Array.liftFrom<string> < 4"', + ); +}); + +// https://discord.com/channels/957797212103016458/1290304355643293747 +it("can extract proto Node at property", () => { + const D = type("Date"); + + const O = type({ + last_updated: D, + }); + + const T = O.get("last_updated"); + + const _protoNode: Eq<typeof T.t, Date> = true; + expect(T.extends(D)).toEqual(true); +}); + +it("piped through Type", () => { + const Letters = type("'a'|'b'|'c'"); + // normally, this would just be .to(Letters), but this should work as + // well, even if it's less efficient + const Letter = type("string").pipe(s => Letters(s)); + + expect(Letter("d").toString()).toEqual('must be "a", "b" or "c" (was "d")'); +}); + +it(".in types are always unionable", () => { + const MorphArrayMorph = type("string") + .pipe(e => e) + .array() + .pipe(e => e); + const OtherType = type("string[]"); + const EitherInput = MorphArrayMorph.in.or(OtherType.in); + + expect(EitherInput(["str"])).toEqual(["str"]); +}); + +it("intersecting unknown with piped type preserves identity", () => { + const Base = type({ + foo: type("string").pipe(() => 123), + }) + .pipe(c => c) + .to({ + foo: "123", + }); + + const Identity = Base.and("unknown"); + + expect(Base({ foo: "x" })).toEqual({ foo: 123 }); + expect(Identity({ foo: "x" })).toEqual({ foo: 123 }); +}); + +it("index signature union intersection with default", () => { + const T = type({ + storeA: "Record<string, string>", + }) + .or({ + storeB: { + foo: "Record<string, string>", + }, + }) + .and({ + ext: ["string", "=", ".txt"], + }); + + expect(T({ storeA: { a: "ok" } })).toEqual({ storeA: { a: "ok" }, ext: ".txt" }); + expect(T({ storeB: { foo: { a: "ok" } } })).toEqual({ + storeB: { foo: { a: "ok" } }, + ext: ".txt", + }); + expect(T({ storeA: { a: 5 } }).toString()).toContain("storeA.a"); +}); + +it("correct toString for array of union", () => { + const T = type("(string | number)[]"); + expect(T(["x", 1])).toEqual(["x", 1]); + expect(T([true]).toString()).toContain("0"); +}); + +it("union with length constraint", () => { + const Feedback = type({ + contact: "string.email | string == 0", + }); + + expect(Feedback({ contact: "" })).toEqual({ contact: "" }); + expect(Feedback({ contact: "me@example.com" })).toEqual({ contact: "me@example.com" }); + expect(Feedback({ contact: "invalid" }).toString()).toContain("contact"); +}); + +it("deleted undeclared keys allowed in input", () => { + const T = type({ foo: "string" }).onUndeclaredKey("delete"); + + const extras = { foo: "hi", bar: 3 }; + + expect(T(extras)).toEqual({ foo: "hi" }); + expect(T.allows(extras)).toEqual(true); + expect(T.in(extras)).toEqual(extras); +}); + +it("deleted undeclared keys rejected in output", () => { + const T = type({ foo: "string" }).onUndeclaredKey("delete"); + + expect(T.out({ foo: "hi", bar: 3 }).toString()).toEqual("bar must be removed"); +}); + +it("distill doesn't treat functions returning any/never as morphs", () => { + type T = { + any(): any; + never(): never; + }; + const _T = type("unknown").as<T>(); +}); + +it("distills morphs returning any/never", () => { + const T = type({ + any: ["unknown", "=>", (): any => {}], + never: ["unknown", "=>", () => [] as never], + }); + expect(T).toBeDefined(); +}); + +// https://github.com/arktypeio/arktype/issues/1274 +it("fail on non-discriminable union of objects with onUndeclaredKey: delete", () => { + const Point2d = type({ + x: "number", + y: "number", + "+": "delete", + }); + + const Point3d = type({ + x: "number", + y: "number", + z: "number", + "+": "delete", + }); + + expect(() => Point2d.or(Point3d)).toThrow("an unordered union with overlapping morph inputs is indeterminate"); +}); + +// https://github.com/arktypeio/arktype/issues/1266 +it("onUndeclaredKey intersection cases", () => { + const types = type.module({ + // Works: overlapping fields are named the same, have simple type + ModelA_V1: { times: "number", "+": "reject" }, + ModelA_V2: { + times: "number", + version: "2", + "+": "reject", + }, + ModelA: "ModelA_V2 | ModelA_V1", + // Works: non-overlapping list fields + ModelB_V1: { times: "number.integer[]", "+": "reject" }, + ModelB_V2: { + frames: "number.integer[]", + version: "2", + "+": "reject", + }, + ModelB: "ModelB_V2 | ModelB_V1", + // Does not work: overlapping array field + ModelC_V1: { times: "number[]", "+": "reject" }, + ModelC_V2: { + times: "number[]", + version: "2", + "+": "reject", + }, + ModelC: "ModelC_V2 | ModelC_V1", + // Works: overlapping map fields + ModelD_V1: { times: "Record<string, number>", "+": "reject" }, + ModelD_V2: { + times: "Record<string, number>", + version: "2", + "+": "reject", + }, + ModelD: "ModelD_V2 | ModelD_V1", + // Works: overlapping user-defined sub-model + Time: { value: "number" }, + ModelE_V1: { time: "Time", "+": "reject" }, + ModelE_V2: { + time: "Time", + version: "2", + "+": "reject", + }, + ModelE: "ModelE_V2 | ModelE_V1", + Times: { values: "number[]" }, + // Does not work: arrays within overlapping sub-model + ModelF_V1: { times: "Times", "+": "reject" }, + ModelF_V2: { + times: "Times", + version: "2", + "+": "reject", + }, + ModelF: "ModelF_V2 | ModelF_V1", + }); + + types.ModelA.assert({ times: 0.0, version: 2 }); + types.ModelB.assert({ frames: [0], version: 2 }); + types.ModelC.assert({ times: [0.0], version: 2 }); + types.ModelD.assert({ times: { age: 7.3 }, version: 2 }); + types.ModelE.assert({ time: { value: 0.0 }, version: 2 }); + types.ModelF.assert({ times: { values: [0.0] }, version: 2 }); +}); + +it("can nested type call from standard schema generic", () => { + function fn< + T extends { + schema: StandardSchemaV1; + }, + >(_: T) { + return {} as StandardSchemaV1.InferOutput<T["schema"]>; + } + + // was inferred as unknown before NoInfer was refactored to conditionals + const arkRes = fn({ + schema: type({ + name: "string", + }), + }); + + const _standardSchema: Eq<typeof arkRes, { name: string }> = true; +}); + +// https://github.com/arktypeio/arktype/issues/1317 +it("discriminated tuple/array union", () => { + const TupleType = type(["number", "number"]); + const TupleArrayType = TupleType.array(); + const UnionType = TupleType.or(TupleArrayType); + + expect(TupleType.assert([1, 2])).toEqual([1, 2]); + expect(TupleArrayType.assert([[1, 2]])).toEqual([[1, 2]]); + expect(UnionType.assert([[1, 2]])).toEqual([[1, 2]]); +}); + +it("doomed shirt example", () => { + const urDOOMed = type({ + grouping: "(0 | (1 | (2 | (3 | (4 | 5)[])[])[])[])[]", + nestedGenerics: "Exclude<0n | unknown[] | Record<string, unknown>, object>", + "escapes\\?": "'a | b' | 'c | d'", + }); + + const _doomed: Eq< + typeof urDOOMed.t, + { + grouping: (0 | (1 | (2 | (3 | (4 | 5)[])[])[])[])[]; + nestedGenerics: 0n; + "escapes?": "a | b" | "c | d"; + } + > = true; + + const valid = { + grouping: [0], + nestedGenerics: 0n, + "escapes?": "a | b", + } as const; + expect(urDOOMed(valid)).toEqual(valid); + expect(urDOOMed({ ...valid, nestedGenerics: {} }).toString()).toContain("nestedGenerics"); +}); + +it.todo("ArkErrors not assignable to ArkErrorInput"); + +it("described input of morph", () => { + class ValidatedUserID { + readonly data: string; + static fromString(value: string): ValidatedUserID { + return new ValidatedUserID(value); + } + private constructor(data: string) { + this.data = data; + } + } + + const UserID = type("string").describe("a userID").pipe.try(ValidatedUserID.fromString); + + const User = type({ + id: UserID, + }); + + const out = User({ + iD: "typo, oops", + }); + + expect(out.toString()).toEqual("id must be a userID (was missing)"); +}); + +// https://github.com/arktypeio/arktype/issues/1400 +it("configured union message", () => { + const Schema = type('"abc" | "cde"').configure({ + message: () => "hello world", + }); + + const res = Schema("efg"); + + expect(res.toString()).toEqual("hello world"); +}); + +it("allows morph union with non-overlapping root objects", () => { + const MasterSkinItem = type({ + "+": "reject", + type: "'skin'", + masterItem: "true", + + skin: "string", + short: "string", + + minPrice: "number.integer >=0", + stattrakAvailable: "boolean = false", + souvenirAvailable: "boolean = false", + qualities: type("1 | 2 | 3 | 4 |5").array().or(["null"]), + }); + + const SkinItem = type({ + "+": "reject", + type: "'skin'", + // "masterItem?": "false", + skin: "string", + short: "string", + + "weight?": "number", + souvenir: "boolean = false", + stattrak: "boolean = false", + quality: type("1 | 2 | 3 | 4 |5").optional(), + }); + + const Skinish = MasterSkinItem.or(SkinItem); + expect( + Skinish({ + type: "skin", + masterItem: true, + skin: "blue", + short: "b", + minPrice: 1, + qualities: [1], + }), + ).toEqual({ + type: "skin", + masterItem: true, + skin: "blue", + short: "b", + minPrice: 1, + qualities: [1], + stattrakAvailable: false, + souvenirAvailable: false, + }); + expect(Skinish({ type: "skin", skin: "blue", short: "b" })).toEqual({ + type: "skin", + skin: "blue", + short: "b", + stattrak: false, + souvenir: false, + }); +}); +it("allows inferring a schema's type argument in a generic wrapper function when the type uses Default", () => { + // biome-ignore lint/complexity/noBannedTypes: generic Type parameter test + function someFunction<TSchema extends Record<string, any>>(schema: Type<TSchema, {}>): (typeof schema)["infer"] { + const someData = { hello: "world" }; + return schema.assert(someData); + } + + const schema = type({ + hello: type("string").pipe(s => s === "world"), + goodbye: "string='blah'", + }); + + someFunction(schema); +}); + +it("allows inferring a schema in a generic wrapper function when the type uses Default", () => { + function someFunction<schema extends type.Any<Record<string, any>>>(schema: schema) { + const someData = { hello: "world" }; + return schema.assert(someData); + } + + const schema = type({ + hello: type("string").pipe(s => s === "world"), + goodbye: "string='blah'", + }); + + someFunction(schema); +}); + +it("reports all string.date errors", () => { + const Thing = type({ + date1: "string.date", + date2: "string.date", + date3: "string.date", + }); + + const out = Thing({ + date1: "", + date2: "", + date3: "", + }); + + if (!(out instanceof type.errors)) throw new AssertionError({}); + + expect(out.length).toEqual(3); + expect(out.summary).toEqual(`date1 must be a parsable date (was "") +date2 must be a parsable date (was "") +date3 must be a parsable date (was "")`); +}); + +// https://github.com/arktypeio/arktype/issues/1188 +it("cyclic discriminated union issue 1", () => { + let wasPiped = false; + + const $ = scope({ + Foo: { + "oneOf?": "Bar[]", // NB: don't get the error if this is not an array + }, + Bar: "Foo", + }).export(); + + const baz = $.Bar.pipe((_: object): type.Any | undefined => { + wasPiped = true; + return type("string"); + }); + + // previously threw "TypeError: this.Foo1Apply is not a function" + const r = baz({ oneOf: [{}] }); + + expect(wasPiped).toEqual(true); + expect(typeof r).toEqual("function"); + expect(r?.("ok")).toEqual("ok"); +}); + +// https://github.com/arktypeio/arktype/issues/1367 +it("cyclic discriminated union issue 2", () => { + const componentModule = type.module({ + container: { + type: "'container'", + content: "component", + }, + + flexbox: { + type: "'flexbox'", + items: "component", + }, + + tabsItem: { + id: "string", + title: "component", + content: "component", + }, + tabs: { + type: "'tabs'", + items: "tabsItem[]", + }, + + singleComponent: "string | flexbox | tabs", + component: "singleComponent | singleComponent[]", + }); + const componentSchema = componentModule.component; + + const component: typeof componentSchema.infer = { + type: "tabs", + items: [ + { + id: "tab-id", + title: "tab-title", + content: [], + }, + ], + }; + + const result = componentSchema(component); + + expect(result).toEqual(component); +}); + +// https://github.com/arktypeio/arktype/issues/1362 +it("cyclic discriminated union with private alias", () => { + const componentModule = type.module({ + "#tabsItem": { + id: "string", + title: "component", + content: "component", + }, + tabs: { + type: "'tabs'", + items: "tabsItem[]", + }, + component: "string | tabs", + }); + const componentSchema = componentModule.component; + + const component: typeof componentSchema.infer = { + type: "tabs", + items: [], + }; + + // previously threw "TypeError: this.tabs1Apply is not a function" + expect(componentSchema(component)).toEqual(component); +}); + +// https://github.com/arktypeio/arktype/issues/1209 +it("cyclic discriminated union issue 3", () => { + const $ = scope({ + literal: '"foo"', + record: { + "[string]": "value", + }, + value: "literal|literal[]|record", + }).export(); + + const result = $.value({}); + + expect(result).toEqual({}); +}); + +// https://github.com/arktypeio/arktype/issues/1284 +it("cyclic discriminated union issue 4", () => { + const ruleset = scope({ + TypeX: { + id: "string", + "+": "reject", + }, + // always worked + TypeA: { + label: "string", + id: "string", + "result?": "TypeA | TypeX", + "+": "reject", + }, + // previously did not work + TypeB: { + label: "string", + id: "string", + "result?": "TypeB | TypeX | null", + "+": "reject", + }, + // always worked + TypeC: { + label: "string", + id: "string", + "result?": "TypeA | TypeX | null", + "+": "reject", + }, + }); + const types = ruleset.export(); + + const data = { + label: "hi", + id: "C", + result: { label: "A", id: "B" }, + }; + + expect(types.TypeA(data)).toEqual(data); + // previously resulted in error: + // result.label must be removed + expect(types.TypeB(data)).toEqual(data); + expect(types.TypeC(data)).toEqual(data); +}); diff --git a/packages/omptype/test/ark/regex.test.ts b/packages/omptype/test/ark/regex.test.ts new file mode 100644 index 000000000..073744acd --- /dev/null +++ b/packages/omptype/test/ark/regex.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { type Type, type } from "@oh-my-pi/omptype/ark"; +import { regex } from "arkregex"; +import type { Eq } from "./type-assert"; + +describe("intersection", () => { + it("distinct strings", () => { + const T = type("/a/&/b/"); + const _type: Eq<typeof T.infer, `${string}a${string}` & `${string}b${string}`> = true; + expect(T.allows("a")).toEqual(false); + expect(T.allows("b")).toEqual(false); + expect(T.allows("ab")).toEqual(true); + }); + + it("identical strings", () => { + const T = type("/a/&/a/"); + expect(T.allows("a")).toBe(true); + }); + + it("string and list", () => { + const Expected = type("/a/&/b/&/c/").json; + expect(Expected).toBeDefined(); + }); + + it("redundant string and list", () => { + const Expected = type("/a/&/b/&/c/").json; + expect(Expected).toBeDefined(); + }); + + it("distinct lists", () => { + const T = type(["/a/&/b/", "&", "/c/&/d/"]); + expect(T.allows("abcd")).toBe(true); + }); + + it("overlapping lists", () => { + const T = type(["/a/&/b/", "&", "/c/&/b/"]); + expect(T.allows("abc")).toBe(true); + }); + + it("identical lists", () => { + const T = type(["/a/&/b/", "&", "/b/&/a/"]); + expect(T.allows("ab")).toBe(true); + }); +}); + +describe("instance", () => { + it("flagless", () => { + const T = type(/.*/); + const _type: Eq<typeof T.infer, string> = true; + }); + + it("single flag preserved", () => { + const T = type(/a/i); + expect(T.allows("A")).toEqual(true); + }); + + it("flag order doesn't matter", () => { + const A = type(/a/gi); + const B = type(/a/gi); + expect(A.json).toEqual(B.json); + }); +}); + +describe("chained", () => { + it("matching", () => { + const T = type("string").matching("foo"); + const Expected = type("/foo/"); + const _type: Eq<typeof T, typeof Expected> = true; + }); + + it("invalid operand", () => { + expect(() => type("number").matching("foo")).toThrow(); + }); +}); + +it("expression doesn't include string basis", () => { + const T = type(/^a.*z$/); + expect(T.allows("abz")).toBe(true); +}); + +it("arkregex integration", () => { + const T = type({ + email: regex("^.*@.*$"), + }); + const _type: Eq< + typeof T, + Type<{ + email: `${string}@${string}`; + }> + > = true; +}); diff --git a/packages/omptype/test/ark/scope.test.ts b/packages/omptype/test/ark/scope.test.ts new file mode 100644 index 000000000..f0b9805cd --- /dev/null +++ b/packages/omptype/test/ark/scope.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from "bun:test"; +import { type ArkErrors, type Module, type Scope, scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("base definition", () => { + const types = scope({ actual: { name: "string" } }).export(); + + const Expected = type({ + name: "string", + }); + + const _assert1: Eq<typeof types.actual.t, typeof Expected.t> = true; + expect(types.actual.expression).toEqual(Expected.expression); + expect(() => scope({ a: "strong" }).export()).toThrow(); +}); + +it("define", () => { + const aliases = scope.define({ foo: "string", bar: { foo: "foo" } }); + expect(aliases).toEqual({ foo: "string", bar: { foo: "foo" } }); +}); + +it("docs example", () => { + const $ = type.scope({ + // built-in keywords are still available in your scope + id: "string", + // but you can also reference your own aliases directly! + user: { id: "id", friends: "id[]" }, + // your aliases will be autocompleted alongside built-in keywords + usersById: { + "[id]": "user | undefined", + }, + }); + + $.export(); + + const _assert2: Eq< + typeof $, + Scope<{ + id: string; + user: { + id: string; + friends: string[]; + }; + usersById: { + [x: string]: + | { + id: string; + friends: string[]; + } + | undefined; + }; + }> + > = true; +}); + +it("type definition inline", () => { + const $ = scope({ actual: type({ name: "string" }) }); + const types = $.export(); + + const Expected = type({ name: "string" }); + + const _assert3: Eq<typeof types.actual.t, typeof Expected.t> = true; + expect(types.actual.expression).toEqual(Expected.expression); + + expect(() => scope({ a: type("strong") })).toThrow(); +}); + +it("interdependent", () => { + const types = scope({ + l: "string > 5", + r: "string.email <= 10", + actual: "l & r", + }).export(); + + const Expected = type("string.email <= 10 & string > 5"); + + const _assert4: Eq<typeof types.actual.t, typeof Expected.t> = true; + expect(types.actual.expression).toEqual(Expected.expression); +}); + +it("object tuple", () => { + const types = scope({ ref: "string", actual: [{ c: "ref" }] }).export(); + const Expected = type([{ c: "string" }]); + + const _assert5: Eq<typeof types.actual.t, typeof Expected.t> = true; + expect(types.actual.expression).toEqual(Expected.expression); +}); + +it("doesn't try to validate any in scope", () => { + const $ = scope({ a: {} as any }); + const resolvedA = $.resolve("a"); + const _assert6: Eq<typeof resolvedA.infer, any> = true; + + const T = $.type(["number", "a"]); + + const _assert7: Eq<typeof T.infer, [number, any]> = true; +}); + +it("infers input and output", () => { + const $ = scope({ + a: ["string", "=>", s => s.length], + }); + const resolvedA = $.resolve("a"); + const _assert8: Eq<typeof resolvedA.infer, number> = true; + const _assert9: Eq<typeof resolvedA.inferIn, string> = true; +}); + +it("infers its own helpers", () => { + const $ = scope({ + a: () => $.type("string"), + b: () => $.type("number"), + }); + const types = $.export(); + + const _assert10: Eq<typeof types.a.infer, string> = true; + expect(types.a.expression).toEqual("string"); + expect(types.a.$.json).toEqual($.json); + + const _assert11: Eq<typeof types.b.infer, number> = true; + expect(types.b.expression).toEqual("number"); + expect(types.b.$.json).toEqual($.json); +}); + +it("allows semantically valid helpers", () => { + const $ = scope({ + n: () => $.type("number"), + lessThan10: () => $.type("n<10"), + }); + const types = $.export(); + + const _assert12: Eq<typeof types.n.t, number> = true; + expect(types.n.expression).toEqual("number"); + + const Expected = type("number").lessThan(10); + + const _assert13: Eq<typeof types.lessThan10.t, typeof Expected.t> = true; + expect(types.lessThan10.expression).toEqual(Expected.expression); +}); + +it("errors on helper parse error", () => { + expect(() => { + const $ = scope({ + a: () => $.type("kung|foo"), + }); + $.export(); + }).toThrow(); +}); + +it("errors on semantically invalid helper", () => { + expect(() => { + const $ = scope({ + b: () => $.type("boolean"), + lessThan10: () => $.type("b<10"), + }); + $.export(); + }).toThrow(); +}); + +it("errors on ridiculous unexpected alias scenario", () => { + expect(() => + scope({ + Unexpected: {}, + User: { + // Previously, using the alias `Unexpected` allowed creating + // this type string which matched its own error message. + name: "Unexpected character 'c'", + }, + }).export(), + ).toThrow(); +}); + +it.todo("autocompletion"); + +it("cross-scope reference", () => { + const { Apple } = scope({ + Apple: { + pear: "Pear", + }, + Pear: { + tasty: "true", + }, + }).export(); + + const { X } = scope({ + X: Apple, + }).export(); + + const out = X({ pear: { tasty: true } }); + expect(out).toEqual({ pear: { tasty: true } }); +}); + +describe("cyclic", () => { + it("base", () => { + const types = scope({ a: { b: "b" }, b: { a: "a" } }).export(); + + const a = {} as { b: typeof b }; + const b = { a }; + a.b = b; + + expect(types.a(a)).toEqual(a); + expect(types.a({ b: { a: { b: { a: 5 } } } }).toString()).toBe("b.a.b.a must be an object (was a number)"); + + // Type hint displays as "..." on hitting cycle (or any if "noErrorTruncation" is true) + void ({} as typeof types.a.infer); + void ({} as typeof types.b.infer.a.b.a.b.a.b.a); + + void ({} as typeof types.a.infer.b.a.b.c); + }); + + const getCyclicScope = () => + scope({ + package: { + name: "string", + "dependencies?": "package[]", + "contributors?": "contributor[]", + }, + contributor: { + email: "string.email", + "packages?": "package[]", + }, + }); + + type Package = { name: string; dependencies?: Package[]; contributors?: { email: string; packages?: Package[] }[] }; + + const getCyclicData = () => { + const packageData = { + name: "arktype", + dependencies: [{ name: "typescript" }], + contributors: [{ email: "david@arktype.io" }], + } satisfies Package; + packageData.dependencies.push(packageData); + return packageData; + }; + + it("cyclic intersection", () => { + const types = scope({ + a: { b: "b&a" }, + b: { a: "a&b" }, + }).export(); + void types.a.t; + void types.b.t; + }); + + it("cyclic union", () => { + const types = scope({ + a: { b: "b|false" }, + b: { a: "a|true" }, + }).export(); + void types.a.t; + void types.b.t; + }); + + it("allows valid", () => { + const types = getCyclicScope().export(); + const data = getCyclicData(); + const out = types.package(data); + expect(out).toBe(data); + expect((out as Package).dependencies?.[1]).toBe(data); + }); + + it("adds errors on invalid", () => { + const types = getCyclicScope().export(); + const data = getCyclicData(); + data.contributors[0].email = "ssalbdivad"; + // ideally would only include one error, see: + // https://github.com/arktypeio/arktype/issues/924 + expect( + types.package(data).toString(), + ).toBe(`dependencies[1].contributors[0].email must be an email address (was "ssalbdivad") +contributors[0].email must be an email address (was "ssalbdivad")`); + }); + + it("can include cyclic data in message", () => { + const data = getCyclicData(); + const nonSelfDependent = getCyclicScope().type([ + "package", + ":", + p => !p.dependencies?.some(d => d.name === p.name), + ]); + expect(nonSelfDependent(data).toString()).toBe( + 'must be valid according to an anonymous predicate (was {"name":"arktype","dependencies":[{"name":"typescript"},"(cycle)"],"contributors":[{"email":"david@arktype.io"}]})', + ); + }); + + it("intersect cyclic reference", () => { + const types = scope({ + arf: { + b: "bork", + }, + bork: { + c: "arf&bork", + }, + }).export(); + void types.arf.infer; + void types.bork.infer; + + const a = {} as typeof types.arf.infer; + const b = { c: {} } as typeof types.bork.infer; + a.b = b; + b.c.b = b; + b.c.c = b.c; + + expect(types.arf(a)).toEqual(a); + expect(types.arf({ b: { c: {} } }).toString()).toBe("b.c.b must be bork (was missing)"); + }); + + it("union cyclic reference", () => { + const types = scope({ + a: { + b: "b", + }, + b: { + a: "a|3", + }, + }).export(); + void types.a.infer; + + const valid: typeof types.a.infer = { b: { a: 3 } }; + + expect(types.a(valid)).toEqual(valid); + + valid.b.a = valid; + + // check cyclic + expect(types.a(valid)).toEqual(valid); + + expect(types.a({ b: { a: { b: { a: 4 } } } }).toString()).toBe("b.a.b.a must be a or 3 (was a number)"); + + void types.b.infer; + }); + + // https://github.com/arktypeio/arktype/issues/1138 + it("cyclic array", () => { + type Value = boolean | number | string | { [k: string]: Value } | Value[]; + + const types = scope({ + primitive: "boolean | number | string", + record: { + "[string]": "value", + }, + value: "primitive | record | value[]", + castValue: "value" as type.cast<Value>, + }).export(); + + // TS type display blows up but it's equivalent to Value + const out = types.value(5); + // casting to Value also works + const castOut = types.value(5); + + const _assert14: Eq<typeof out, Value | ArkErrors> = true; + expect(out).toEqual(5); + const _assert15: Eq<typeof castOut, Value | ArkErrors> = true; + expect(castOut).toEqual(5); + }); +}); + +it("can override ambient aliases", () => { + const types = scope({ + foo: { + bar: "string", + }, + string: type.number, + }).export(); + const _assert16: Eq< + typeof types, + Module<{ + string: number; + foo: { + bar: number; + }; + }> + > = true; + expect(types.foo({ bar: 1 })).toEqual({ bar: 1 }); + expect(types.foo({ bar: "1" }).toString()).toBe("bar must be a number (was a string)"); +}); + +it("module", () => { + const types = type.module({ + foo: "string", + bar: "number", + }); + const _assert17: Eq< + typeof types, + Module<{ + foo: string; + bar: number; + }> + > = true; + expect(types.foo("ok")).toBe("ok"); + expect(types.bar(1)).toBe(1); +}); diff --git a/packages/omptype/test/ark/select.test.ts b/packages/omptype/test/ark/select.test.ts new file mode 100644 index 000000000..89fd3d40c --- /dev/null +++ b/packages/omptype/test/ark/select.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("docs select config example", () => { + const SelectivelyConfigured = type({ + name: "string", + age: "number", + }).configure( + { + description: "a special string", + }, + // only add the description to string keywords + { + kind: "domain", + where: d => d.domain === "string", + }, + ); + + expect(SelectivelyConfigured.get("name").description).toBe("a special string"); + expect(SelectivelyConfigured.get("age").description).toBe("a number"); +}); diff --git a/packages/omptype/test/ark/serialization.test.ts b/packages/omptype/test/ark/serialization.test.ts new file mode 100644 index 000000000..8545cc56f --- /dev/null +++ b/packages/omptype/test/ark/serialization.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +it("built-in prototypes", () => { + const A = type({ + age: "number", + }); + + const B = type({ + ages: A.array(), + }); + + const serialized = JSON.stringify(B.toJsonSchema()); + const deserialized: unknown = JSON.parse(serialized); + + expect(deserialized).toEqual(B.toJsonSchema()); +}); diff --git a/packages/omptype/test/ark/standardSchema.test.ts b/packages/omptype/test/ark/standardSchema.test.ts new file mode 100644 index 000000000..2953063a3 --- /dev/null +++ b/packages/omptype/test/ark/standardSchema.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("validation conforms to spec", () => { + const T = type({ foo: "string" }); + const standard: StandardSchemaV1<{ foo: string }> = T; + const standardOut = standard["~standard"].validate({ + foo: "bar", + }); + expect<promisable<StandardSchemaV1.Result<{ foo: string }>>>(standardOut).toEqual({ + value: { foo: "bar" }, + }); + + const badStandardOut = standard["~standard"].validate({ + foo: 5, + }) as StandardSchemaV1.FailureResult; + + expect(badStandardOut.issues).toBeInstanceOf(type.errors); + expect(badStandardOut.issues.toString()).toEqual("foo must be a string (was a number)"); +}); + +it("can infer generic parameter from standard schema", () => { + const acceptsStandardSchema = <T extends StandardSchemaV1>( + _schema: T, + ): { + input: StandardSchemaV1.InferInput<T>; + output: StandardSchemaV1.InferOutput<T>; + } => ({}) as never; + + const result = acceptsStandardSchema(type({ foo: "string.numeric.parse" })); + + const _1: Eq<typeof result.input, { foo: string }> = true; + const _2: Eq<typeof result.output, { foo: number }> = true; +}); + +describe("~standard.jsonSchema", () => { + it("generates input schema with draft-2020-12", () => { + const T = type({ foo: "string" }); + const standard: StandardJSONSchemaV1 = T; + const jsonSchema = standard["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }); + expect(jsonSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("generates output schema with draft-2020-12", () => { + const T = type({ foo: "string" }); + const standard: StandardJSONSchemaV1 = T; + const jsonSchema = standard["~standard"].jsonSchema.output({ + target: "draft-2020-12", + }); + expect(jsonSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("generates input schema with draft-07", () => { + const T = type({ foo: "string" }); + const standard: StandardJSONSchemaV1 = T; + const jsonSchema = standard["~standard"].jsonSchema.input({ + target: "draft-07", + }); + expect(jsonSchema).toEqual({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("generates output schema with draft-07", () => { + const T = type({ foo: "string" }); + const standard: StandardJSONSchemaV1 = T; + const jsonSchema = standard["~standard"].jsonSchema.output({ + target: "draft-07", + }); + expect(jsonSchema).toEqual({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("passes libraryOptions to toJsonSchema", () => { + const T = type({ foo: "string" }); + const standard: StandardJSONSchemaV1 = T; + const jsonSchema = standard["~standard"].jsonSchema.input({ + target: "draft-2020-12", + libraryOptions: { + dialect: null, + }, + }); + expect(jsonSchema).toEqual({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("throws for unsupported target", () => { + const T = type({ foo: "string" }); + const standard: StandardJSONSchemaV1 = T; + expect(() => + standard["~standard"].jsonSchema.input({ + target: "openapi-3.0", + }), + ).toThrow('JSONSchema target \'openapi-3.0\' is not supported (must be "draft-2020-12" or "draft-07")'); + }); + + it("generates different input/output schemas for morphs", () => { + const T = type({ foo: "string.numeric.parse" }); + const standard: StandardJSONSchemaV1 = T; + + const inputSchema = standard["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }); + expect(inputSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + foo: { + type: "string", + pattern: "^(?:(?!^-0\\.?0*$)(?:-?(?:(?:0|[1-9]\\d*)(?:\\.\\d+)?)|\\.\\d+?))$", + }, + }, + required: ["foo"], + }); + + const outputSchema = standard["~standard"].jsonSchema.output({ + target: "draft-2020-12", + }); + expect(outputSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "number" } }, + required: ["foo"], + }); + }); +}); diff --git a/packages/omptype/test/ark/string.test.ts b/packages/omptype/test/ark/string.test.ts new file mode 100644 index 000000000..2920032d3 --- /dev/null +++ b/packages/omptype/test/ark/string.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("errors on empty string", () => { + expect(() => type("")).toThrow(); +}); + +it("ignores whitespace between identifiers/operators", () => { + const T = type(` \n string | + number + \t|boolean [] `); + const _type: Eq<typeof T.infer, string | number | boolean[]> = true; + expect(T.json).toEqual(type("string|number|boolean[]").json); +}); + +it("errors on bad whitespace", () => { + expect(() => type("string | boo lean[]")).toThrow(); +}); + +it("unterminated string", () => { + expect(() => type("'bob")).toThrow(); +}); + +it.todo("shallow single autocomplete"); + +it.todo("shallow multi autocomplete"); + +it.todo("post-operator autocomplete"); + +it.todo("post-operator autocomplete with spaces"); diff --git a/packages/omptype/test/ark/submodule.test.ts b/packages/omptype/test/ark/submodule.test.ts new file mode 100644 index 000000000..b465e94ee --- /dev/null +++ b/packages/omptype/test/ark/submodule.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "bun:test"; +import { + type BoundModule, + type Module, + type Scope, + type Submodule, + scope, + type Type, + type, +} from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("submodule", () => { + const $ = scope({ + a: "string", + b: "sub.alias", + sub: scope({ alias: "number" }).export(), + }); + it("base", () => { + const types = $.export(); + const _assert1: Eq< + typeof types, + Module<{ + a: string; + b: number; + sub: Submodule<{ + alias: number; + }>; + }> + > = true; + + const _assert2: Eq<typeof types.sub.alias.infer, number> = true; + expect(types.sub.alias(1)).toBe(1); + expect(types.b(1)).toBe(1); + }); + + it("non-submodule dot access", () => { + expect(() => $.type("b.foo")).toThrow(); + }); + + it("thunk submodule", () => { + const $ = scope({ + a: "string", + c: "a", + sub: () => + scope({ + ...$.import("a", "c"), + foo: "a", + bar: "foo", + }).export(), + }); + const _assert3: Eq< + typeof $, + Scope<{ + a: string; + c: string; + sub: Submodule<{ + foo: string; + bar: string; + }>; + }> + > = true; + }); + + it("no alias reference", () => { + expect(() => $.type("sub")).toThrow(); + }); + + it("bad alias reference", () => { + expect(() => $.type("sub.marine")).toThrow(); + }); + + it.todo("completions"); + + it("can reference subaliases in expression", () => { + const dateFrom = type("string.date.parse | Date"); + + void dateFrom.t; + + expect(dateFrom("05-21-1993")).toBeInstanceOf(Date); + expect(dateFrom(new Date())).toBeInstanceOf(Date); + + expect(dateFrom("foobar").toString()).toBe('must be a parsable date or a Date (was "foobar")'); + }); + + it("allows unbound module in scope", () => { + const mod = scope({ + a: "number", + }).export(); + + const use1 = scope({ + mod, + b: "mod.a", + }); + + void use1; + + const types = use1.export(); + expect(types.b(1)).toBe(1); + expect(types.b("1").toString()).toBe("must be a number (was a string)"); + }); + + // https://github.com/arktypeio/arktype/issues/1103 + it("allows BoundModule reference in scope", () => { + const mod2 = scope({ + a: "number", + c: "string", + }).export("a"); + + const use2 = scope({ + mod2, + b: "mod2.a", + }); + + void use2; + + const types = use2.export(); + expect(types.b(1)).toBe(1); + expect(types.b("1").toString()).toBe("must be a number (was a string)"); + }); +}); + +describe("rooted submodules", () => { + const foo = type.module({ root: "'foo'", bar: "'bar'" }); + + const $ = scope({ + foo, + fooBare: "foo", + fooBar: "foo.bar", + }); + + it("base", () => { + const _assert4: Eq< + typeof $, + Scope<{ + foo: Submodule<{ + root: "foo"; + bar: "bar"; + }>; + fooBare: "foo"; + fooBar: "bar"; + }> + > = true; + + const types = $.export(); + + expect(types.foo.bar.expression).toBe('"bar"'); + expect(types.foo.root.expression).toBe('"foo"'); + + expect(types.fooBar.expression).toBe('"bar"'); + expect(types.fooBare.expression).toBe('"foo"'); + }); + + it.todo("completions"); + + it("docs example", () => { + const userModule = type.module({ + root: { + name: "string", + }, + // subaliases can extend a base type by referencing 'root' + // like any other alias + admin: { + "...": "root", + isAdmin: "true", + }, + saiyan: { + "...": "root", + powerLevel: "number > 9000", + }, + }); + + const rootScope = type.scope({ + user: userModule, + // user can now be referenced directly in a definition + group: "user[]", + // or used as a prefix to access subaliases + elevatedUser: "user.admin | user.saiyan", + }); + + const types = rootScope.export(); + expect(types.group([{ name: "Ada" }])).toEqual([{ name: "Ada" }]); + expect(types.elevatedUser({ name: "Ada", isAdmin: true })).toEqual({ name: "Ada", isAdmin: true }); + expect(types.elevatedUser({ name: "Goku", powerLevel: 9001 })).toEqual({ + name: "Goku", + powerLevel: 9001, + }); + }); +}); + +describe("nested submodule", () => { + const $ = scope({ + outer: scope({ + inner: scope({ + alias: "1", + }).export(), + }).export(), + }); + + type Expected$ = { + outer: Submodule<{ + inner: Submodule<{ + alias: 1; + }>; + }>; + }; + + it("export", () => { + const types = $.export(); + + const _assert5: Eq<typeof types, Module<Expected$>> = true; + + const _assert6: Eq< + typeof types.outer, + BoundModule< + { + inner: Submodule<{ + alias: 1; + }>; + }, + Expected$ + > + > = true; + const _assert7: Eq< + typeof types.outer.inner, + BoundModule< + { + alias: 1; + }, + Expected$ + > + > = true; + const _assert8: Eq<typeof types.outer.inner.alias, Type<1, Expected$>> = true; + + expect(types.outer.inner.alias.expression).toEqual("1"); + expect(types.outer.inner.alias(1)).toBe(1); + }); + + it("reference", () => { + const T = $.type(["outer.inner.alias"]); + const _assert9: Eq<typeof T, Type<[1], Expected$>> = true; + expect(T.expression).toBe("[1]"); + }); + + it("non-submodule dot access", () => { + expect(() => + type({ + a: "true.subtype", + }), + ).toThrow(); + }); + + it.todo("completions"); + + type _DeepExpected$ = { + a: Submodule<{ + b: Submodule<{ + c: Submodule<{ + d: Submodule<{ + e: Submodule<{ + f: Submodule<{ + g: Submodule<{ + alias: 1; + }>; + }>; + }>; + }>; + }>; + }>; + }>; + }; + + it.todo("deep"); +}); diff --git a/packages/omptype/test/ark/this.test.ts b/packages/omptype/test/ark/this.test.ts new file mode 100644 index 000000000..38395e85e --- /dev/null +++ b/packages/omptype/test/ark/this.test.ts @@ -0,0 +1,101 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("resolves from type", () => { + const DisappointingGift = type({ + label: "string", + "box?": "this", + }); + + type ExpectedDisappointingGift = { + label: string; + box?: ExpectedDisappointingGift; + }; + const _assert1: Eq<typeof DisappointingGift.infer, ExpectedDisappointingGift> = true; + + expect(DisappointingGift({ label: "foo" })).toEqual({ label: "foo" }); + expect(DisappointingGift({ label: "foo", box: { label: "bar" } })).toEqual({ + label: "foo", + box: { label: "bar" }, + }); + expect( + DisappointingGift({ + label: "foo", + box: { label: "bar", box: {} }, + }).toString(), + ).toBe("box.box.label must be a string (was missing)"); +}); + +it("at nested path", () => { + const T = type({ foo: { bar: "this" } }); + + void T; + + const validData = { foo: { bar: {} } } as typeof T.infer; + validData.foo.bar = validData; + + expect(T(validData)).toEqual(validData); + + const invalidData = { foo: { bar: {} as any } }; + invalidData.foo.bar = invalidData.foo; + expect(T(invalidData).toString()).toBe("foo.bar.foo must be an object (was missing)"); +}); + +it("this preserved when referencing at path", () => { + const Initial = type({ + initial: "this", + }); + + const Reference = type({ + reference: Initial, + }); + type Initial = { + initial: Initial; + }; + type Expected = { + reference: Initial; + }; + + const _assert2: Eq<typeof Reference.infer, Expected> = true; + + const initialData = {} as typeof Initial.infer; + initialData.initial = initialData; + + const referenceData = { reference: initialData }; + + expect(Initial(initialData)).toEqual(initialData); + expect(Reference(referenceData)).toEqual(referenceData); + expect(Reference({ reference: {} }).toString()).toBe("reference.initial must be an object (was missing)"); +}); + +it("unresolvable in scope", () => { + expect(() => + scope({ + disappointingGift: { + label: "string", + "box?": "this", + }, + }).export(), + ).toThrow(); +}); + +it("tuple expression", () => { + const T = type([{ a: "string" }, "|", { b: "this" }]); + void T.infer; + expect(T({ a: "foo" })).toEqual({ a: "foo" }); + expect(T({ b: { a: "bar" } })).toEqual({ b: { a: "bar" } }); + expect(T({ b: { b: {} } }).toString()).toBe( + "a must be a string (was missing) or b.a must be a string (was missing) or b.b.a must be a string (was missing) or b.b.b must be an object (was missing)", + ); +}); + +it("root expression", () => { + const T = type({ a: "string" }, "|", { b: "this" }); + void T.infer; + expect(T({ a: "foo" })).toEqual({ a: "foo" }); + expect(T({ b: { a: "bar" } })).toEqual({ b: { a: "bar" } }); + expect(T({ b: { b: {} } }).toString()).toBe( + "a must be a string (was missing) or b.a must be a string (was missing) or b.b.a must be a string (was missing) or b.b.b must be an object (was missing)", + ); +}); diff --git a/packages/omptype/test/ark/thunk.test.ts b/packages/omptype/test/ark/thunk.test.ts new file mode 100644 index 000000000..e803e2abe --- /dev/null +++ b/packages/omptype/test/ark/thunk.test.ts @@ -0,0 +1,138 @@ +import { expect, it } from "bun:test"; +import { type Scope, scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("in type", () => { + const T = type(() => type("boolean")); + const _assert1: Eq<typeof T.infer, boolean> = true; + expect(() => { + type(() => type("moolean")); + }).toThrow(); +}); + +it("in scope", () => { + const $ = scope({ + a: () => $.type({ b: "b" }), + b: () => $.type({ a: "string" }), + }); + const _assert2: Eq< + (typeof $)["t"], + { + a: { + b: { + a: string; + }; + }; + b: { + a: string; + }; + } + > = true; + + const types = $.export(); + const _assert3: Eq< + typeof types.a.infer, + { + b: { + a: string; + }; + } + > = true; + + expect(types.a({ b: { a: "ok" } })).toEqual({ b: { a: "ok" } }); + const _assert4: Eq<typeof types.b.infer, { a: string }> = true; + + expect(types.b({ a: "ok" })).toEqual({ a: "ok" }); +}); + +it("expression from thunk", () => { + const $ = scope({ + a: () => $.type({ a: "string" }), + b: { b: "boolean" }, + aAndB: () => $.type("a&b"), + }); + const types = $.export(); + const _assert5: Eq<typeof types.aAndB.infer, { a: string; b: boolean }> = true; + expect(types.aAndB({ a: "ok", b: true })).toEqual({ a: "ok", b: true }); + expect(types.aAndB({ a: "ok", b: 1 }).toString()).toBe("b must be boolean (was a number)"); +}); + +it("shallow in type", () => { + const T = type(() => type("string")); + expect(T("ok")).toBe("ok"); + const _assert6: Eq<typeof T.infer, string> = true; +}); + +it("deep in type", () => { + const T = type({ a: () => type("string") }); + expect(T.json).toEqual(type({ a: "string" }).json); + const _assert7: Eq<typeof T.infer, { a: string }> = true; +}); + +it("non-type thunk in scope", () => { + const $ = scope({ + a: () => 42, + }); + expect(() => $.export()).toThrow("number"); +}); + +it("parse error in thunk in scope", () => { + const $ = scope({ + a: () => $.type("bad"), + }); + expect(() => $.export()).toThrow("bad"); +}); + +it("docs example", () => { + const $ = type.scope({ + id: "string#id", + expandUserGroup: () => + $.type({ + name: "string", + id: "id", + }) + .or("id") + .pipe(function _docsExampleThunkMorph(user) { + return typeof user === "string" ? { id: user, name: "Anonymous" } : user; + }) + .array() + .atLeastLength(2), + }); + + const _docsExample: Eq< + typeof $, + Scope<{ + id: string & { readonly __brand: "id" }; + expandUserGroup: (( + In: + | string + | { + name: string; + id: string; + }, + ) => { + name: string; + id: string & { readonly __brand: "id" }; + })[]; + }> + > = true; + + const types = $.export(); + + const groups = types.expandUserGroup([{ name: "Magical Crawdad", id: "777" }, "778"]); + + type BrandedId = typeof types.id.t; + + expect(groups).toEqual([ + { name: "Magical Crawdad", id: "777" as BrandedId }, + { id: "778" as BrandedId, name: "Anonymous" }, + ]); +}); + +it("docs inelegant", () => { + // you *can* use them anywhere, but *should* you? (no) + const Inelegant = type(() => type({ inelegantKey: () => type("'inelegant value'") })); + + void Inelegant.t; + expect(Inelegant.expression).toBe('{ inelegantKey: "inelegant value" }'); +}); diff --git a/packages/omptype/test/ark/toJsonSchema.test.ts b/packages/omptype/test/ark/toJsonSchema.test.ts new file mode 100644 index 000000000..abd2e5a6d --- /dev/null +++ b/packages/omptype/test/ark/toJsonSchema.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; + +describe("target option", () => { + it("omits the dialect until a target is requested", () => { + const T = type({ foo: "string" }); + const schema = T.toJsonSchema(); + expect(schema).toEqual({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("generates draft-2020-12 schema when specified", () => { + const T = type({ foo: "string" }); + const schema = T.toJsonSchema({ target: "draft-2020-12" }); + expect(schema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); + + it("generates draft-07 schema when specified", () => { + const T = type({ foo: "string" }); + const schema = T.toJsonSchema({ target: "draft-07" }); + expect(schema).toEqual({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }); + }); +}); + +describe("draft-specific syntax", () => { + it("draft-2020-12 uses prefixItems for tuples", () => { + const T = type(["string", "number"]); + const schema = T.toJsonSchema({ target: "draft-2020-12" }); + expect(schema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + items: false, + minItems: 2, + }); + }); + + it("draft-07 uses items array for tuples", () => { + const T = type(["string", "number"]); + const schema = T.toJsonSchema({ target: "draft-07" }); + expect(schema).toEqual({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "array", + items: [{ type: "string" }, { type: "number" }], + additionalItems: false, + minItems: 2, + }); + }); + + it("draft-2020-12 uses items for variadic tuple elements", () => { + const T = type(["string", "...", "number[]"]); + const schema = T.toJsonSchema({ target: "draft-2020-12" }); + expect(schema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ type: "string" }], + items: { type: "number" }, + minItems: 1, + }); + }); + + it("draft-07 uses additionalItems for variadic tuple elements", () => { + const T = type(["string", "...", "number[]"]); + const schema = T.toJsonSchema({ target: "draft-07" }); + expect(schema).toEqual({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "array", + items: [{ type: "string" }], + additionalItems: { type: "number" }, + minItems: 1, + }); + }); + + it("draft-2020-12 uses $defs for references", () => { + const types = type.module({ + user: { + name: "string", + friend: "user?", + }, + }); + const schema = types.user.toJsonSchema({ + target: "draft-2020-12", + }) as Record<string, unknown>; + expect("$defs" in schema).toBe(true); + expect("definitions" in schema).toBe(false); + expect(Object.keys(schema.$defs as object).length > 0).toBe(true); + }); + + it("draft-07 uses definitions for references", () => { + const types = type.module({ + user: { + name: "string", + friend: "user?", + }, + }); + const schema = types.user.toJsonSchema({ + target: "draft-07", + }) as Record<string, unknown>; + expect("definitions" in schema).toBe(true); + expect("$defs" in schema).toBe(false); + expect(Object.keys(schema.definitions as object).length > 0).toBe(true); + }); +}); diff --git a/packages/omptype/test/ark/traverse.test.ts b/packages/omptype/test/ark/traverse.test.ts new file mode 100644 index 000000000..b9d4b3860 --- /dev/null +++ b/packages/omptype/test/ark/traverse.test.ts @@ -0,0 +1,262 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; + +it("divisible", () => { + const T = type("number%2"); + expect(T(4)).toEqual(4); + expect(T(5).toString()).toEqual("must be a number divisible by 2 (was 5)"); +}); + +it("range", () => { + const T = type("number>2"); + expect(T(3)).toEqual(3); + expect(T(2).toString()).toEqual("must be a number more than 2 (was 2)"); +}); + +it("domain", () => { + const T = type("number"); + expect(T(5)).toEqual(5); + expect(T("foo").toString()).toEqual("must be a number (was a string)"); +}); + +it("pattern", () => { + const T = type("/.*@arktype.io/"); + expect(T("shawn@arktype.io")).toEqual("shawn@arktype.io"); + expect(T("shawn@hotmail.com").toString()).toEqual( + 'must be a string matching /.*@arktype.io/ (was "shawn@hotmail.com")', + ); +}); + +it("required keys", () => { + const T = type({ + name: "string", + age: "number", + "title?": "string", + }); + expect(T({ name: "Shawn", age: 99 })).toEqual({ + name: "Shawn", + age: 99, + }); + expect(T({ name: "Shawn" }).toString()).toEqual("age must be a number (was missing)"); +}); + +it("customized built-in problem", () => { + const types = scope( + { isEven: "number%2" }, + { + divisor: { + expected: ctx => `% ${ctx.rule} !== 0`, + problem: ctx => `${ctx.actual} ${ctx.expected}`, + }, + }, + ).export(); + expect(types.isEven(3).toString()).toEqual("3 % 2 !== 0"); +}); + +it("domains", () => { + const T = type("string|number[]"); + expect(T([1])).toEqual([1]); + expect(T("hello")).toEqual("hello"); + expect(T(2).toString()).toEqual("must be a string or an array (was a number)"); + expect(T({}).toString()).toEqual("must be a string or an array (was an object)"); +}); + +it("tuple length", () => { + const T = type(["string", "number", "string", "string[]"]); + const data: typeof T.infer = ["foo", 5, "boo", []]; + expect(T(data)).toEqual(data); + expect(T(["hello"]).toString()).toEqual("must be an array of at least length 4 (was an array)"); +}); + +it("branches", () => { + const T = type({ bar: "boolean" }, "|", { foo: "string" }); + expect(T({ foo: "ok" })).toEqual({ foo: "ok" }); + expect(T({ bar: true })).toEqual({ bar: true }); + expect(T({}).toString()).toEqual("bar must be boolean (was missing) or foo must be a string (was missing)"); + expect(T({ bar: "swapped", foo: true }).toString()).toEqual( + 'bar must be boolean (was "swapped") or foo must be a string (was true)', + ); +}); + +it("common errors collapse", () => { + const T = type({ base: "1", a: "1" }, "|", { base: "1", b: "1" }); + expect(T({ base: 1, a: 1 })).toEqual({ base: 1, a: 1 }); + expect(T({ base: 1, b: 1 })).toEqual({ base: 1, b: 1 }); + expect(T({ a: 1, b: 1 }).toString()).toEqual("base must be 1 (was missing)"); +}); + +it("branches at path", () => { + const T = type({ key: [{ a: "string" }, "|", { b: "boolean" }] }); + expect(T({ key: { a: "ok" } })).toEqual({ key: { a: "ok" } }); + expect(T({ key: { b: true } })).toEqual({ key: { b: true } }); + expect(T({ key: {} }).toString()).toEqual( + "key.a must be a string (was missing) or key.b must be boolean (was missing)", + ); +}); + +it("switch", () => { + const T = type({ a: "string" }).or({ a: "null" }).or({ a: "number" }); + expect(T({ a: "ok" })).toEqual({ a: "ok" }); + expect(T({ a: 5 })).toEqual({ a: 5 }); + // value isn't present + expect(T({}).toString()).toEqual("a must be a string, null or a number (was missing)"); + // unsatisfying value + expect(T({ a: false }).toString()).toEqual("a must be a string, null or a number (was false)"); +}); + +// previously was affected by a caching issue +// https://github.com/arktypeio/arktype/issues/962 +it("multiple switch", () => { + const types = scope({ + a: { foo: "string" }, + b: { foo: "number" }, + c: { foo: "Function" }, + d: "a|b|c", + }).export(); + expect(types.d({}).toString()).toEqual("foo must be a string, a number or an object (was missing)"); + expect(types.d({ foo: null }).toString()).toEqual("foo must be a string, a number or an object (was null)"); +}); + +it("serialized actual for discriminated union", () => { + const T = type({ a: "'foo'" }).or({ a: "'bar'" }); + expect(T({ a: '"extra quotes"' }).toString()).toEqual('a must be "foo" or "bar" (was "\\"extra quotes\\"")'); + expect(T({ a: "" }).toString()).toEqual('a must be "foo" or "bar" (was "")'); + expect(T({ a: 5 }).toString()).toEqual('a must be "foo" or "bar" (was 5)'); +}); + +it("multi", () => { + const NaturalNumber = type("number.integer>0"); + expect(NaturalNumber(-1.2).toString()).toEqual(`(-1.2) must be... + ◦ an integer + ◦ positive`); + const NaturalAtPath = type({ + natural: NaturalNumber, + }); + expect(NaturalAtPath({ natural: -0.1 }).toString()).toEqual(`natural (-0.1) must be... + ◦ an integer + ◦ positive`); +}); + +it("multiple errors across paths", () => { + const NaturalSchema = type({ + natural: "number.integer>0", + name: "string", + }); + const result = NaturalSchema({ + natural: -Math.PI, + name: ["negative", "PI"], + }); + expect(result.toString()).toEqual(`natural must be an integer (was -3.141592653589793) +natural must be positive (was -3.141592653589793) +name must be a string (was an object)`); +}); + +it("homepage example", () => { + const User = type({ + name: "string", + luckyNumbers: "(number | bigint)[]", + "isAdmin?": "boolean | null", + }); + + const out = User({ + luckyNumbers: [31, "255", 1337n], + isAdmin: 1, + }); + + expect(out.toString()).toEqual(`name must be a string (was missing) +luckyNumbers[1] must be a number or a bigint (was a string) +isAdmin must be boolean or null (was a number)`); +}); + +it("relative path", () => { + const Signup = type({ + email: "string.email", + password: "string", + repeatPassword: "string", + }).narrow( + (d, ctx) => + d.password === d.repeatPassword || + ctx.reject({ + expected: "identical to password", + actual: "", + relativePath: ["repeatPassword"], + }), + ); + + // ensure the relativePath is relative + const NestedSignup = type({ + user: Signup, + }); + + const validSignup: typeof Signup.infer = { + email: "david@arktype.io", + password: "secure", + repeatPassword: "secure", + }; + + const valid: typeof NestedSignup.infer = { user: validSignup }; + + expect(NestedSignup(valid)).toEqual(valid); + expect( + NestedSignup({ + user: { ...validSignup, repeatPassword: "insecure" }, + }).toString(), + ).toEqual("user.repeatPassword must be identical to password"); +}); + +// https://github.com/arktypeio/arktype/issues/1149 +it("morphs apply when not at an error path, even on failed validation", () => { + const AgeType = type("string.numeric.parse").to("number>18"); + const ObjType = type({ name: "string", "age?": AgeType }); + + const out = ObjType({ name: 2, age: "2" }); + expect(out.toString()).toEqual(`name must be a string (was a number) +age must be a number more than 18 (was 2)`); +}); + +it("morphs don't apply when at an error path", () => { + let callCount = 0; + const T = type("unknown") + .narrow((_data, ctx) => ctx.mustBe("valid")) + .pipe(() => callCount++); + + const out = T(1); + + expect(out.toString()).toEqual("must be valid (was 1)"); + expect(callCount).toEqual(0); +}); + +it("morphs don't apply when under an error path", () => { + let callCount = 0; + const T = type({ + foo: ["unknown", "=>", () => callCount++], + }).filter((_data, ctx) => ctx.mustBe("valid")); + + const out = T({ foo: 1 }); + + expect(out.toString()).toEqual('must be valid (was {"foo":1})'); + expect(callCount).toEqual(0); +}); + +it("ctx.path docs example", () => { + const symbolicKey = Symbol("ctxPathExampleSymbol"); + + let path: PropertyKey[] | undefined; + + const notFoo = type.string.narrow((s, ctx) => { + if (s !== "foo") return true; + path = ctx.path.slice(0); + return ctx.mustBe("not foo"); + }); + + const Obj = type({ + stringKey: { + [symbolicKey]: notFoo.array(), + }, + }); + + expect(Obj({ stringKey: { [symbolicKey]: ["bar", "foo"] } }).toString()).toEqual( + 'stringKey[Symbol(ctxPathExampleSymbol)][1] must be not foo (was "foo")', + ); + expect(path).toEqual(["stringKey", "Symbol(ctxPathExampleSymbol)", 1]); +}); diff --git a/packages/omptype/test/ark/type-assert.ts b/packages/omptype/test/ark/type-assert.ts new file mode 100644 index 000000000..5fbb95454 --- /dev/null +++ b/packages/omptype/test/ark/type-assert.ts @@ -0,0 +1,11 @@ +/** + * Type-level assertion helpers for the ported arktype test suite. + * + * `Eq` is `true` only when `a` and `b` are exactly the same type, letting + * ported `attest<X>(T.infer)` assertions survive as compile-time checks: + * + * ```ts + * const _check: Eq<typeof T.infer, { name: string }> = true; + * ``` + */ +export type Eq<a, b> = (<t>() => t extends a ? 1 : 2) extends <t>() => t extends b ? 1 : 2 ? true : false; diff --git a/packages/omptype/test/ark/type.test.ts b/packages/omptype/test/ark/type.test.ts new file mode 100644 index 000000000..14f8cbea3 --- /dev/null +++ b/packages/omptype/test/ark/type.test.ts @@ -0,0 +1,183 @@ +import { expect, it } from "bun:test"; +import { TraversalError, type Type, type } from "@oh-my-pi/omptype/ark"; + +import assert = require("node:assert/strict"); + +import type { Eq } from "./type-assert"; + +it("root discriminates", () => { + const T = type("string"); + const out = T(""); + if (out instanceof type.errors) out.throw(); + else expect<string>(out); +}); + +it("allows", () => { + const T = type("number%2"); + const data: unknown = 4; + if (T.allows(data)) { + // narrows correctly + expect<number>(data); + } else throw new Error(); + + expect(T.allows(5)).toEqual(false); +}); + +it("allows doc example", () => { + const Numeric = type("number | bigint"); + const numerics = [0, "one", 2n].filter(Numeric.allows); + expect(numerics).toEqual([0, 2n]); +}); + +it("extends doc example", () => { + const N = type("boolean | string"); + expect(N.expression).toEqual("boolean | string"); + expect(N.ifExtends("boolean")).toBeUndefined(); +}); + +it("errors can be thrown", () => { + const T = type("number"); + try { + const result = T("invalid"); + if (result instanceof type.errors) result.throw(); + } catch (e) { + expect(e).toBeInstanceOf(TraversalError); + expect((e as TraversalError).arkErrors instanceof type.errors); + return; + } + throw new assert.AssertionError({ message: "Expected to throw" }); +}); + +it("assert", () => { + const T = type({ a: "string" }); + expect(T.assert({ a: "1" })).toEqual({ a: "1" }); + expect(() => T.assert({ a: 1 })).toThrow("a must be a string (was a number)"); +}); + +it("distribute", () => { + const T = type("===", 0, "1", "2", 3, "4", 5); + + const numbers = T.distribute( + n => n.ifExtends(type.number) ?? type.raw(n.expression.slice(1, -1)).as<number>(), + branches => type.raw(branches).as<number[]>(), + ); + + expect(numbers.expression).toEqual("[0, 1, 2, 3, 4, 5]"); +}); + +it("ark attached", () => { + expect<string>(type.keywords.number.integer.expression).toEqual("number % 1"); +}); + +it("unit", () => { + const T = type.unit(5); + const _unitInference: Eq<typeof T.infer, 5> = true; + expect(T.expression).toEqual("5"); +}); + +it("enumerated", () => { + const T = type.enumerated(5, true, null); + const _enumeratedInference: Eq<typeof T.infer, 5 | true | null> = true; + expect(T.expression).toEqual("5 | true | null"); +}); + +it("ifEquals", () => { + const T = type("string"); + expect(T.ifEquals("string")).toEqual(T); + // subtype + expect(T.ifEquals("'foo'")).toEqual(undefined); + // supertype + expect(T.ifEquals("string | number")).toEqual(undefined); +}); + +it("ifExtends", () => { + const T = type("string"); + expect<type<string> | undefined>(T.ifExtends("string")).toEqual(T); + // subtype + expect<type<"foo"> | undefined>(T.ifExtends("'foo'")).toEqual(undefined); + // supertype + expect<type<string | number> | undefined>(T.ifExtends("string | number")).toEqual(T); +}); + +it("allows assignment to unparameterized Type", () => { + const T = type({ + name: "string >= 2", + email: "string.email", + }); + + T satisfies Type; +}); + +it("allows morph assignment to unparameterized Type", () => { + const T = type("string").pipe(s => s.length); + + T satisfies Type; +}); + +it("assert callable as standalone function", () => { + const { assert } = type("string"); + + expect<(data: unknown) => string>(assert); + expect(assert("foo")).toEqual("foo"); + expect(() => assert(5)).toThrow("must be a string (was a number)"); +}); + +it("valueOf", () => { + // 🪦R.I.P. TS enums🪦 + // 2012-2025 + // Killed by --erasableSyntaxOnly + + // enum TsEnum { + // numeric = 1, + // symmetrical = "symmetrical", + // asymmetrical = "lacirtemmysa" + // } + + const EquivalentObject = { + numeric: 1, + symmetrical: "symmetrical", + asymmetrical: "lacirtemmysa", + } as const; + + // TS reverse assigns numeric values + // need to make sure we don't extract them at runtime + + // Object.assign avoids TS inferring this key (it wouldn't for an enum) + Object.assign(EquivalentObject, { + "1": "numeric", + }); + + const T = type.valueOf(EquivalentObject); + + const Expected = type.enumerated(1, "symmetrical", "lacirtemmysa"); + + expect<typeof Expected>(T); + expect(T.expression).toEqual(Expected.expression); +}); + +it("toJsonSchema docs", () => { + const User = type({ + name: "string", + email: "string.email", + "age?": "number >= 18", + }); + + const schema = User.toJsonSchema({ target: "draft-2020-12" }); + + const expected: JsonSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + email: { + type: "string", + format: "email", + pattern: "^[\\w%+.-]+@[\\d.A-Za-z-]+\\.[A-Za-z]{2,}$", + }, + age: { type: "number", minimum: 18 }, + }, + required: ["name", "email"], + }; + + expect(schema).toEqual(expected); +}); diff --git a/packages/omptype/test/ark/typeReference.test.ts b/packages/omptype/test/ark/typeReference.test.ts new file mode 100644 index 000000000..a1606cc90 --- /dev/null +++ b/packages/omptype/test/ark/typeReference.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("shallow type reference", () => { + const T = type(type("boolean")); + const _assert1: Eq<typeof T.infer, boolean> = true; +}); + +it("bad shallow type reference", () => { + expect(() => { + type(type("foolean")); + }).toThrow(); +}); + +it("deep type reference", () => { + const T = type({ a: type("boolean") }); + const _assert2: Eq<typeof T.infer, { a: boolean }> = true; +}); + +it("type reference in scope", () => { + const A = type({ a: "string" }); + const $ = scope({ a: A }); + const types = $.export(); + expect(types.a.json).toEqual(A.json); + const _assert3: Eq<typeof types.a.infer, { a: string }> = true; +}); + +it("bad deep type reference", () => { + expect(() => { + type({ a: type("goolean") }); + }).toThrow(); +}); diff --git a/packages/omptype/test/ark/unenclosed.test.ts b/packages/omptype/test/ark/unenclosed.test.ts new file mode 100644 index 000000000..b12aa824b --- /dev/null +++ b/packages/omptype/test/ark/unenclosed.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { scope, type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +describe("identifier", () => { + it("keyword", () => { + const T = type("string"); + const _infer: Eq<typeof T.infer, string> = true; + expect(_infer).toBe(true); + }); + + it("alias", () => { + const a = scope({ a: "string" }).type("a"); + const _infer: Eq<typeof a.infer, string> = true; + expect(_infer).toBe(true); + }); + + it("unresolvable", () => { + expect(() => type("HUH")).toThrow(); + }); +}); + +describe("number", () => { + it("positive whole", () => { + const Four = type("4"); + const _infer: Eq<typeof Four.infer, 4> = true; + expect(_infer).toBe(true); + }); + + it("positive decimal", () => { + const T = type("3.14159"); + const _infer: Eq<typeof T.infer, 3.14159> = true; + expect(_infer).toBe(true); + }); + + it("positive decimal with zero whole portion", () => { + const T = type("0.5"); + const _infer: Eq<typeof T.infer, 0.5> = true; + expect(_infer).toBe(true); + }); + + it("negative whole", () => { + const T = type("-12"); + const _infer: Eq<typeof T.infer, -12> = true; + expect(_infer).toBe(true); + }); + + it("negative decimal", () => { + const T = type("-1.618"); + const _infer: Eq<typeof T.infer, -1.618> = true; + expect(_infer).toBe(true); + }); + + it("negative decimal with zero whole portion", () => { + const T = type("-0.001"); + const _infer: Eq<typeof T.infer, -0.001> = true; + expect(_infer).toBe(true); + }); + + it("zero", () => { + const T = type("0"); + const _infer: Eq<typeof T.infer, 0> = true; + expect(_infer).toBe(true); + }); + + it("multiple decimals", () => { + expect(() => type("127.0.0.1")).toThrow(); + }); + + it("with alpha", () => { + expect(() => type("13three7")).toThrow(); + }); + + it("leading zeroes", () => { + expect(() => type("010")).toThrow("Malformed number literal '010'"); + }); + + it("trailing zeroes", () => { + expect(() => type("4.0")).toThrow("Malformed number literal '4.0'"); + }); + + it("negative zero", () => { + expect(() => type("-0")).toThrow("Malformed number literal '-0'"); + }); +}); + +describe("bigint", () => { + it("positive", () => { + const T = type("12345678910987654321n"); + const _infer: Eq<typeof T.infer, 12345678910987654321n> = true; + expect(_infer).toBe(true); + }); + + it("negative", () => { + const T = type("-9801n"); + const _infer: Eq<typeof T.infer, -9801n> = true; + expect(_infer).toBe(true); + }); + + it("zero", () => { + const T = type("0n"); + const _infer: Eq<typeof T.infer, 0n> = true; + expect(_infer).toBe(true); + }); + + it("decimal", () => { + expect(() => type("999.1n")).toThrow(); + }); + + it("leading zeroes", () => { + expect(() => type("007n")).toThrow("Malformed number literal '007n'"); + }); + + it("negative zero", () => { + expect(() => type("-0n")).toThrow("Malformed number literal '-0n'"); + }); +}); diff --git a/packages/omptype/test/ark/union.test.ts b/packages/omptype/test/ark/union.test.ts new file mode 100644 index 000000000..4b5af733b --- /dev/null +++ b/packages/omptype/test/ark/union.test.ts @@ -0,0 +1,222 @@ +import { expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype/ark"; +import type { Eq } from "./type-assert"; + +it("binary", () => { + const Binary = type("number|string"); + const _1: Eq<typeof Binary.infer, number | string> = true; + expect(Binary.json).toEqual(["number", "string"]); +}); + +it("nary", () => { + const Nary = type("false|null|undefined|0|''"); + const _2: Eq<typeof Nary.infer, false | "" | 0 | null | undefined> = true; + const Expected = type("===", false, null, undefined, 0, ""); + expect(Nary.json).toEqual(Expected.json); +}); + +it("subtype pruning", () => { + const T = type({ a: "string" }, "|", { a: "'foo'" }); + const Expected = type({ a: "string" }); + const _3: Eq<typeof T, typeof Expected> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("multiple subtypes pruned", () => { + const T = type("'foo'|'bar'|string|'baz'|/.*/"); + const Expected = type("string"); + const _4: Eq<typeof T.infer, string> = true; + expect(T.json).toEqual(Expected.json); +}); + +it("boolean is a union of true | false", () => { + const T = type("true|false"); + + expect(T.json).toEqual(type("boolean").json); +}); + +it("nested tuple union", () => { + const T = type(["string|bigint", "|", ["number", "|", "boolean"]]); + const _5: Eq<typeof T.infer, string | number | bigint | boolean> = true; + expect(T.json).toEqual(type("string|bigint|number|boolean").json); +}); + +it("length stress", () => { + // as of TS 5.1, can handle a max of 46 branches before an inifinitely + // deep error not the end of the world if this changes slightly, but + // wanted to make those changes explicit if something reduces it it's + // also still very responsive up until it hits the limit, so it is + // likely a safeguard rather than a limitation of the parser + const T = type( + "0|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34|35|36|37|38|39|40|41|42|43|44|45", + ); + // prettier-ignore + const _6: Eq< + typeof T.infer, + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24 + | 25 + | 26 + | 27 + | 28 + | 29 + | 30 + | 31 + | 32 + | 33 + | 34 + | 35 + | 36 + | 37 + | 38 + | 39 + | 40 + | 41 + | 42 + | 43 + | 44 + | 45 + > = true; + + expect(T.json).toEqual([ + { unit: 0 }, + { unit: 1 }, + { unit: 2 }, + { unit: 3 }, + { unit: 4 }, + { unit: 5 }, + { unit: 6 }, + { unit: 7 }, + { unit: 8 }, + { unit: 9 }, + { unit: 10 }, + { unit: 11 }, + { unit: 12 }, + { unit: 13 }, + { unit: 14 }, + { unit: 15 }, + { unit: 16 }, + { unit: 17 }, + { unit: 18 }, + { unit: 19 }, + { unit: 20 }, + { unit: 21 }, + { unit: 22 }, + { unit: 23 }, + { unit: 24 }, + { unit: 25 }, + { unit: 26 }, + { unit: 27 }, + { unit: 28 }, + { unit: 29 }, + { unit: 30 }, + { unit: 31 }, + { unit: 32 }, + { unit: 33 }, + { unit: 34 }, + { unit: 35 }, + { unit: 36 }, + { unit: 37 }, + { unit: 38 }, + { unit: 39 }, + { unit: 40 }, + { unit: 41 }, + { unit: 42 }, + { unit: 43 }, + { unit: 44 }, + { unit: 45 }, + ]); +}); + +it("tuple", () => { + const T = type([{ a: "string" }, "|", { b: "number" }]); + const _7: Eq<typeof T.infer, { a: string } | { b: number }> = true; + expect(T.allows({ a: "ok" })).toBe(true); + expect(T.allows({ b: 1 })).toBe(true); + expect(T.allows({ a: 1 })).toBe(false); +}); + +it("root", () => { + const T = type({ a: "string" }, "|", { b: "number" }); + const _8: Eq<typeof T.infer, { a: string } | { b: number }> = true; + expect(T.allows({ a: "ok" })).toBe(true); + expect(T.allows({ b: 1 })).toBe(true); + expect(T.allows({ b: "bad" })).toBe(false); +}); + +it("chained", () => { + const T = type({ a: "string" }).or({ b: "number" }); + const _9: Eq< + typeof T.infer, + | { + a: string; + } + | { + b: number; + } + > = true; + expect(T.allows({ a: "ok" })).toBe(true); + expect(T.allows({ b: 1 })).toBe(true); + expect(T.allows({ a: 1, b: "bad" })).toBe(false); +}); + +it.todo("root autocompletion"); + +it("bad reference", () => { + expect(() => type("number|strng")).toThrow(); +}); + +it("consecutive tokens", () => { + expect(() => type("boolean||null")).toThrow(); +}); + +it("ends with |", () => { + expect(() => type("boolean|")).toThrow(); +}); + +it("long missing union member", () => { + expect(() => type("boolean[]|(string|number|)|object")).toThrow(); +}); + +it("left semantic error", () => { + expect(() => type("symbol%2|string")).toThrow(); +}); + +it("right semantic error", () => { + expect(() => type("string|symbol%2")).toThrow(); +}); + +it("chained bad reference", () => { + expect(() => type("string").or("nummer")).toThrow(); +}); + +it("chained description", () => { + const T = type("number|string").describe("My custom type"); + expect(T.description).toBe("My custom type"); + expect(T.toJsonSchema().description).toBe("My custom type"); + expect(T.allows(1)).toBe(true); + expect(T.allows("one")).toBe(true); +}); diff --git a/packages/omptype/test/from-json-schema.test.ts b/packages/omptype/test/from-json-schema.test.ts new file mode 100644 index 000000000..3180cb5b9 --- /dev/null +++ b/packages/omptype/test/from-json-schema.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; +import { fromJsonSchema, OmpErrors, type } from "../src"; + +describe("fromJsonSchema", () => { + it("round-trips schemas emitted by toJsonSchema", () => { + const original = type({ + name: "1 <= string <= 32", + "retries?": "number.integer >= 0", + mode: "'read' | 'write'", + tags: "string[]", + }); + const imported = fromJsonSchema(original.toJsonSchema()); + const valid = { name: "job", mode: "read", tags: ["a"] }; + expect(imported(valid)).toEqual(valid); + expect(imported({ name: "job", mode: "delete", tags: [] })).toBeInstanceOf(OmpErrors); + expect(imported({ name: "", mode: "read", tags: [] })).toBeInstanceOf(OmpErrors); + expect(imported({ mode: "read", tags: [] })).toBeInstanceOf(OmpErrors); + expect(imported({ name: "job", mode: "read", tags: [1] })).toBeInstanceOf(OmpErrors); + }); + + it("treats defaulted properties as fillable, matching emitted required semantics", () => { + const imported = fromJsonSchema({ + type: "object", + properties: { level: { type: "string", default: "info" }, source: { type: "string" } }, + required: ["source"], + }); + expect(imported({ source: "api" })).toEqual({ source: "api", level: "info" }); + expect(imported({})).toBeInstanceOf(OmpErrors); + }); + + it("supports enum, const, unions, intersections, and boolean schemas", () => { + expect(fromJsonSchema({ enum: ["a", "b"] })("a")).toBe("a"); + expect(fromJsonSchema({ enum: ["a", "b"] })("c")).toBeInstanceOf(OmpErrors); + expect(fromJsonSchema({ const: 5 })(5)).toBe(5); + expect(fromJsonSchema({ anyOf: [{ type: "string" }, { type: "null" }] })(null)).toBe(null); + const bounded = fromJsonSchema({ + allOf: [ + { type: "number", minimum: 0 }, + { type: "number", maximum: 3 }, + ], + }); + expect(bounded(2)).toBe(2); + expect(bounded(4)).toBeInstanceOf(OmpErrors); + expect(fromJsonSchema(true)("anything")).toBe("anything"); + expect(fromJsonSchema(false)("anything")).toBeInstanceOf(OmpErrors); + }); + + it("validates string formats and patterns", () => { + const email = fromJsonSchema({ type: "string", format: "email" }); + expect(email("a@omp.sh")).toBe("a@omp.sh"); + expect(email("nope")).toBeInstanceOf(OmpErrors); + const patterned = fromJsonSchema({ type: "string", pattern: "^G-[A-Z0-9]+$", minLength: 3 }); + expect(patterned("G-X1")).toBe("G-X1"); + expect(patterned("bad")).toBeInstanceOf(OmpErrors); + expect(patterned("G-")).toBeInstanceOf(OmpErrors); + }); + + it("resolves recursive $defs references", () => { + const imported = fromJsonSchema({ + $defs: { + node: { + type: "object", + properties: { value: { type: "number" }, next: { $ref: "#/$defs/node" } }, + required: ["value"], + }, + }, + $ref: "#/$defs/node", + }); + expect(imported({ value: 1, next: { value: 2 } })).toEqual({ value: 1, next: { value: 2 } }); + const invalid = imported({ value: 1, next: { value: "two" } }); + expect(invalid).toBeInstanceOf(OmpErrors); + if (invalid instanceof OmpErrors) expect(invalid[0].path).toEqual(["next", "value"]); + }); + + it("imports tuples with variadic tails and rejects undeclared keys on closed objects", () => { + const pair = fromJsonSchema({ + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + minItems: 1, + items: { type: "boolean" }, + }); + expect(pair(["x", 1, true])).toEqual(["x", 1, true]); + expect(pair(["x"])).toEqual(["x"]); + expect(pair([1])).toBeInstanceOf(OmpErrors); + + const closed = fromJsonSchema({ + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + additionalProperties: false, + }); + expect(closed({ a: "x" })).toEqual({ a: "x" }); + expect(closed({ a: "x", b: 1 })).toBeInstanceOf(OmpErrors); + }); + + it("emits recursive scopes as $defs/$ref and round-trips them", () => { + const Node = type.scope({ Node: { value: "number", "next?": "Node" } }).export().Node; + const emitted = Node.toJsonSchema({ target: "draft-2020-12" }); + expect(JSON.stringify(emitted)).toContain("#/$defs/"); + expect(emitted.$defs).toBeDefined(); + + const imported = fromJsonSchema(emitted); + expect(imported({ value: 1, next: { value: 2, next: { value: 3 } } })).toEqual({ + value: 1, + next: { value: 2, next: { value: 3 } }, + }); + expect(imported({ value: 1, next: { value: "two" } })).toBeInstanceOf(OmpErrors); + + const draft7 = Node.toJsonSchema({ target: "draft-07" }); + expect(JSON.stringify(draft7)).toContain("#/definitions/"); + expect(JSON.stringify(draft7)).not.toContain("$defs"); + }); + + it("emits io-aware schemas: input keeps morph sources and optional defaults, output the reverse", () => { + const S = type({ port: "number = 8080", raw: "string.integer.parse" }); + + const input = S.toJsonSchema({ io: "input" }); + const inputProps = input.properties as Record<string, Record<string, unknown>>; + expect(inputProps.raw.type).toBe("string"); + expect(input.required).toEqual(["raw"]); + expect(inputProps.port.default).toBe(8080); + + const output = S.toJsonSchema({ io: "output" }); + const outputProps = output.properties as Record<string, Record<string, unknown>>; + expect(outputProps.raw.type).toBe("integer"); + expect(output.required).toEqual(["port", "raw"]); + + // The emitted input schema accepts what the source schema accepts. + const imported = fromJsonSchema(input); + expect(imported({ raw: "42" })).toEqual({ raw: "42", port: 8080 }); + expect(imported({ raw: 42 })).toBeInstanceOf(OmpErrors); + }); + + it("io output uses the .to() target for piped schemas", () => { + const Uid = type("number.integer > 0 | string.integer.parse | bigint") + .pipe(value => (typeof value === "bigint" ? Number(value) : value)) + .to("number.integer > 0"); + expect(Uid.assert("42")).toBe(42); + expect(Uid.assert(7n)).toBe(7); + expect(Uid(0)).toBeInstanceOf(OmpErrors); + + const output = Uid.toJsonSchema({ io: "output" }); + expect(output).toEqual({ type: "integer", exclusiveMinimum: 0 }); + + const input = Uid.toJsonSchema({ io: "input" }); + expect(JSON.stringify(input)).toContain("anyOf"); + + // A bare pipe AFTER .to() makes the output statically unknown again — + // output emission degrades to the unconstrained schema, never the stale + // .to target and never the input union. + const stringified = Uid.pipe(value => String(value)); + expect(stringified.toJsonSchema({ io: "output" })).toEqual({}); + + // Structural-only schemas keep emitting their own IR for output io. + expect(type("string").toJsonSchema({ io: "output" })).toEqual({ type: "string" }); + // Embedded in an object property, the .to target still drives output io. + const wrapped = type({ id: Uid }); + const wrappedOut = wrapped.toJsonSchema({ io: "output" }); + const props = wrappedOut.properties as Record<string, unknown>; + expect(props.id).toEqual({ type: "integer", exclusiveMinimum: 0 }); + }); +}); 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<a, b> = (<t>() => t extends a ? 1 : 2) extends <t>() => t extends b ? 1 : 2 ? true : false; + +const _string: Eq<InferDef<"string">, string> = true; +const _number: Eq<InferDef<"number">, number> = true; +const _integer: Eq<InferDef<"number.integer">, number> = true; +const _boolean: Eq<InferDef<"boolean">, boolean> = true; +const _null: Eq<InferDef<"null">, null> = true; +const _undefined: Eq<InferDef<"undefined">, undefined> = true; +const _unknown: Eq<InferDef<"unknown">, unknown> = true; +const _absent: Eq<InferDef, unknown> = true; +const _object: Eq<InferDef<"object">, object> = true; +const _bigint: Eq<InferDef<"bigint">, bigint> = true; +const _url: Eq<InferDef<"string.url">, string> = true; +const _singleQuoted: Eq<InferDef<"'x'">, "x"> = true; +const _doubleQuoted: Eq<InferDef<'"x"'>, "x"> = true; +const _digit: Eq<InferDef<"5">, 5> = true; +const _true: Eq<InferDef<"true">, true> = true; +const _false: Eq<InferDef<"false">, false> = true; +const _literalUnion: Eq<InferDef<"'a' | 'b'">, "a" | "b"> = true; +const _nullableUnion: Eq<InferDef<"string | null">, string | null> = true; +const _spacedUnion: Eq<InferDef<" string\t|\n null ">, string | null> = true; +const _array: Eq<InferDef<"string[]">, string[]> = true; +const _unionArray: Eq<InferDef<"(string | number)[]">, (string | number)[]> = true; +const _nestedArray: Eq<InferDef<"string[][]">, string[][]> = true; +const _lowerBound: Eq<InferDef<"number >= 0">, number> = true; +const _stringBounds: Eq<InferDef<"1 <= string <= 10">, string> = true; +const _numberBounds: Eq<InferDef<"0 < number <= 3600">, number> = true; +const _numberDefault: Eq<InferDef<"number = 5">, number> = true; +const _stringDefault: Eq<InferDef<"string='eve'">, string> = true; +const _booleanDefault: Eq<InferDef<"boolean = false">, boolean> = true; +const _garbage: Eq<InferDef<"not valid">, unknown> = true; +const _dateBound: Eq<InferDef<"Date >= d'2020-01-01'">, Date> = true; +const _parseDateOutput: Eq<InferDef<"string.date.iso.parse">, Date> = true; +const _parseDateInput: Eq<InferDefIn<"string.date.iso.parse">, string> = true; +const _parseUrlOutput: Eq<InferDef<"parse.url">, URL> = true; +const _parseUrlInput: Eq<InferDefIn<"parse.url">, string> = true; +const _partialRecord: Eq<InferDef<"Partial<Record<string, number>>">, Partial<Record<string, number>>> = 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<InferDef<ObjectDef>, ObjectOut> = true; +const _mutableTupleArray: Eq<InferDef<["number", "[]"]>, number[]> = true; +const _readonlyTupleArray: Eq<InferDef<readonly ["number", "[]"]>, number[]> = true; +const _record: Eq<InferDef<{ "[string]": "boolean" }>, Record<string, boolean>> = true; +const _recordWithProps: Eq< + InferDef<{ name: "string"; "[string]": "unknown" }>, + { name: string } & Record<string, unknown> +> = true; + +type Embedded = { infer: { id: number } }; +type DefaultedEmbedded = { infer: string; readonly hasDefault: true }; +const _embedded: Eq<InferDef<Embedded>, { id: number }> = true; +const _embeddedUnionMember: Eq< + InferDef<{ value: Embedded | { infer: null } }>, + { value: { id: number } | null } +> = true; +const _embeddedDefault: Eq<InferDef<{ "name?": DefaultedEmbedded }>, { name: string }> = true; +const _inputAlias: Eq<InferDefIn<ObjectDef>, ObjectIn> = true; +const _badProperty: Eq<InferDef<{ value: symbol }>, { value: unknown }> = true; +type MorphTuple = readonly ["string.numeric.parse", readonly ["number", "=", 1]]; +const _morphTupleOutput: Eq<InferDef<MorphTuple>, [number, number]> = true; +const _morphTupleInput: Eq<InferDefIn<MorphTuple>, [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<InferDef<FlatTool>, { command: string; timeout: number; cwd?: string }> = true; +const _arkEnum: Eq<InferDef<EnumObject>, { mode: "fast" | "safe"; verbose?: boolean }> = true; +const _arkNested: Eq<InferDef<NestedTuple>, { matrix: number[][] }> = true; +const _arkRecord: Eq<InferDef<StringRecord>, Record<string, string>> = true; +const _arkBounds: Eq<InferDef<" 0 < number <= 3600 ">, number> = true; +// Definition-level default output is number. +const _arkDefaultOutput: Eq<InferDef<"number = 5">, 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..becaa3944 --- /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", () => { + // A fixed tuple is closed by `additionalItems: false`; ArkType emits no `maxItems`. + expect(irToJsonSchema(parseDef(["string", "number"]), { target: "draft-07" })).toEqual({ + type: "array", + items: [{ type: "string" }, { type: "number" }], + minItems: 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..5412d5c5e --- /dev/null +++ b/packages/omptype/test/type.test.ts @@ -0,0 +1,493 @@ +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<typeof type>[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(".allows() preserves nested and optional-property validation", () => { + const s = type({ + context: "string", + tasks: [{ "name?": "1 <= string <= 32", task: "string" }, "[]"], + }); + expect(s.allows({ context: "goal", tasks: [{ task: "run" }] })).toBe(true); + expect(s.allows({ context: "goal", tasks: [{ name: "", task: "run" }] })).toBe(false); + expect(s.allows({ context: "goal", tasks: [{ name: undefined, task: "run" }] })).toBe(false); + expect(s.allows({ context: "goal", tasks: [{ task: 1 }] })).toBe(false); + }); + + 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<string, User>", + Public: "Pick<User, 'name'>", + Maybe: "Partial<User>", + }) + .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: "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 <= 3600").equals(type("number > 0").and("number <= 3600"))).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); + + // ArkType applies `.configure()` to the node it is called on (and its shallow + // descendants), so the constraint that should carry the config owns it here. + const configured = type({ + user: { + age: type("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"]); + } + } + }); +}); + +describe("Standard Schema V1", () => { + it("validates synchronously with morphs and path-aware issues", () => { + const s = type({ port: "string.integer.parse", name: "string" }); + const std = s["~standard"]; + expect(std.version).toBe(1); + expect(std.vendor).toBe("omptype"); + expect(std.validate({ port: "8080", name: "api" })).toEqual({ value: { port: 8080, name: "api" } }); + const failed = std.validate({ port: "8080", name: 42 }); + if (failed instanceof Promise || failed.issues === undefined) throw new Error("expected sync failure"); + expect(failed.issues[0].path).toEqual(["name"]); + expect(failed.issues[0].message).toContain("a string"); + }); + + it("materializes root defaults for undefined input at the standard boundary", () => { + const staticDefault = type.string.default("dev"); + expect(staticDefault["~standard"].validate(undefined)).toEqual({ value: "dev" }); + expect(staticDefault(undefined)).toBe("dev"); + expect(staticDefault("prod")).toBe("prod"); + expect(staticDefault(5)).toBeInstanceOf(OmpErrors); + + // Factory defaults run per call — distinct instances each time. + const factoryDefault = type("string[]").default(() => []); + const first = factoryDefault(undefined); + const second = factoryDefault(undefined); + expect(first).toEqual([]); + expect(first).not.toBe(second); + }); +}); 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<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; +type Assert<T extends true> = 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<Eq<Static<typeof literal>, "yes">>; + type _Tuple = Assert<Eq<Static<typeof tuple>, [string, number]>>; + type ExpectedObject = { id: number } & { label?: string }; + type _Object = Assert<Eq<Static<typeof object>, 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<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; +type Assert<T extends true> = 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<z.infer<typeof typedUser>, { name: string; nickname?: string | undefined; score: number; tags: string[] }> +>; +const optionalText = z.string().optional(); +type _OptionalInference = Assert<Eq<z.infer<typeof optionalText>, string | undefined>>; +const transformed = z.string().transform(value => value.length); +type _TransformInference = Assert<Eq<z.infer<typeof transformed>, number>>; +const nullableOptional = z.number().nullable().optional(); +type _NullableOptionalInference = Assert<Eq<z.infer<typeof nullableOptional>, 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..5d8354429 --- /dev/null +++ b/packages/omptype/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.workspace.json", + "include": ["src", "test", "bench"], + "exclude": ["node_modules", "dist", "test/ark"] +} 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/snapcompact/package.json b/packages/snapcompact/package.json index b554ca071..cefb9c8a6 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "17.2.6", + "version": "17.2.8", "description": "Bitmap-frame context compression for vision-capable LLMs", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/snapcompact/research/bench_kimi.py b/packages/snapcompact/research/bench_kimi.py index 5d020a6db..7a6957202 100644 --- a/packages/snapcompact/research/bench_kimi.py +++ b/packages/snapcompact/research/bench_kimi.py @@ -2,16 +2,20 @@ # requires-python = ">=3.10" # dependencies = ["pillow"] # /// -"""Kimi K2.6 chunked benchmark runner (KimiK26Bench scratch file). +"""Kimi-line chunked benchmark runner over PRODUCTION-rendered frames. mono_prod protocol (same flow/questions/seed) but every request carries <=8 frames: OpenRouter silently drops images after the first 8, and kimi itself dilutes >8 genuine frames. Chunk plan = windows of 8 frames, overlap >=1, evenly spread (reproduces the diag [(0,8),(6,14),(13,21)] plan for 21 frames -so the .973 anchor is a cache hit). Questions are routed to the chunk where -their answer position is most interior. +so the k2.6 .973 anchor is a cache hit). Questions are routed to the chunk +where their answer position is most interior. - uv run --with pillow python bench_kimi.py --shape-json '<Shape>' --name <label> + uv run bench_kimi.py --shape 8on16-bw # k2.6 anchor + uv run bench_kimi.py --model moonshotai/kimi-k3 --shape 8on22-bw + +--shape takes a settings variant name from mono_prod.SHAPES (the production +SHAPE_VARIANTS payloads); --shape-json + --name still accept a raw Shape. """ import argparse @@ -26,10 +30,11 @@ sys.path.insert(0, str(HERE)) import squad # noqa: E402 from final import MODELS, cached # noqa: E402 +from mono_prod import SHAPES # noqa: E402 from providers import _png_b64, _post, load_env_key, llm_complete # noqa: E402 from run import CACHE, RESULTS, load_prompt, sha8 # noqa: E402 -OR_MODEL = "moonshotai/kimi-k2.6" +DEFAULT_MODEL = "moonshotai/kimi-k2.6" FW_MODEL = "accounts/fireworks/models/kimi-k2p6" FW_URL = "https://api.fireworks.ai/inference/v1/chat/completions" FW_PRICE = (0.95, 4.00) # $/M in, out — fireworks.ai/models/fireworks/kimi-k2p6 @@ -94,8 +99,14 @@ def plan_chunks(n: int) -> list[tuple[int, int]]: def main() -> None: ap = argparse.ArgumentParser() - ap.add_argument("--shape-json", required=True) - ap.add_argument("--name", required=True) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument( + "--shape", choices=sorted(SHAPES), help="named shape from mono_prod.SHAPES" + ) + ap.add_argument( + "--shape-json", help="raw production Shape JSON (alternative to --shape)" + ) + ap.add_argument("--name", help="condition label; required with --shape-json") ap.add_argument( "--route", default="openrouter", choices=["openrouter", "fireworks"] ) @@ -112,7 +123,18 @@ def main() -> None: ap.add_argument("--fresh", action="store_true") args = ap.parse_args() - shape, label = json.loads(args.shape_json), args.name + if args.shape_json: + if not args.name: + ap.error("--name is required with --shape-json") + shape, label = json.loads(args.shape_json), args.name + elif args.shape: + shape, label = SHAPES[args.shape], args.shape + else: + ap.error("pass --shape or --shape-json") + if args.route == "fireworks" and args.model != DEFAULT_MODEL: + ap.error("the fireworks route is wired to kimi-k2.6 only") + if args.route == "openrouter" and args.model not in MODELS: + ap.error(f"model {args.model} not in final.MODELS; add its prices there") keys = { "openrouter": load_env_key("OPENROUTER_API_KEY"), "anthropic": "", @@ -144,8 +166,13 @@ def main() -> None: pngs = sorted(frame_dir.glob("page-*.png")) n_frames = len(pngs) size = shape["frameSize"] - cols = size // shape["cellWidth"] - rows = size // shape["cellHeight"] + repeat = shape.get("lineRepeat", 1) + cols = ( + (size // shape["cellWidth"] - 3) // 2 + if shape.get("columns") == 2 + else size // shape["cellWidth"] + ) + rows = size // shape["cellHeight"] // repeat chunks = plan_chunks(n_frames) print(f"{label}: {n_frames} frames @{size}px, chunks={chunks}") @@ -170,6 +197,17 @@ def main() -> None: preamble = load_prompt("qa-image-multi.md").format( k=len(chunk_pngs), cols=cols, rows=rows ) + if shape.get("columns") == 2: + preamble += ( + "\nNote: each image lays text out as two word-wrapped newspaper columns separated by a gutter; " + "read the left column top to bottom, then the right column." + ) + if repeat > 1: + preamble += ( + f"\nNote: every text line is rendered {repeat} times consecutively - first on the plain " + "background, then repeated on a pale highlight band. The copies show identical characters; " + "cross-check between them when a glyph is hard to read, and do not treat copies as separate text." + ) ctx = [ {"text": preamble}, *({"image_path": p} for p in chunk_pngs), @@ -187,7 +225,7 @@ def main() -> None: zip( ("text", "usage", "stop"), llm_complete( - keys, OR_MODEL, m, max_tokens=args.max_tokens, effort=None + keys, args.model, m, max_tokens=args.max_tokens, effort=None ), ) ) @@ -196,7 +234,7 @@ def main() -> None: zip(("text", "usage", "stop"), fireworks_complete(m, args.max_tokens)) ) qa = cached( - OR_MODEL, tag, {"messages": messages, "effort": None}, fn, args.fresh + args.model, tag, {"messages": messages, "effort": None}, fn, args.fresh ) for q, a in zip(qs, squad.parse_numbered(qa["text"], len(qs))): answers_by_q[q["q"]] = a @@ -208,7 +246,7 @@ def main() -> None: rows_out = [ { - "model": OR_MODEL, + "model": args.model, "cond": f"bench-{label}", "pos_rel": q["pos_rel"], "q": q["q"], @@ -224,7 +262,9 @@ def main() -> None: k: sum(x[k] for x in usages) for k in ("in", "out", "cache_w", "cache_r", "reasoning") } - price_in, price_out = MODELS[OR_MODEL] if args.route == "openrouter" else FW_PRICE + price_in, price_out = ( + MODELS[args.model] if args.route == "openrouter" else FW_PRICE + ) cost = u["in"] / 1e6 * price_in + u["out"] / 1e6 * price_out quart = [] for lo, hi in ((0, 0.25), (0.25, 0.5), (0.5, 0.75), (0.75, 1.01)): @@ -258,7 +298,7 @@ def main() -> None: "q3": quart[2], "q4": quart[3], } - out_dir = RESULTS / f"bench-kimi-{label}" + out_dir = RESULTS / f"bench-{args.model.split('/')[-1]}-{label}" out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "records.jsonl").write_text("\n".join(json.dumps(r) for r in rows_out)) (out_dir / "summary.json").write_text(json.dumps([summary], indent=1)) diff --git a/packages/snapcompact/research/final.py b/packages/snapcompact/research/final.py index 7e75032c9..44051f1dd 100644 --- a/packages/snapcompact/research/final.py +++ b/packages/snapcompact/research/final.py @@ -59,6 +59,7 @@ MODELS = { "gpt-5.4-mini": (0.75, 4.5), "google/gemini-3.5-flash": (0.6, 4.0), "moonshotai/kimi-k2.6": (0.68, 3.41), + "moonshotai/kimi-k3": (3.00, 15.00), "z-ai/glm-4.6v": (0.30, 0.90), } LENGTHS = (50, 150, 250) diff --git a/packages/snapcompact/research/mono_prod.py b/packages/snapcompact/research/mono_prod.py index a5abd83ee..e525d97e3 100644 --- a/packages/snapcompact/research/mono_prod.py +++ b/packages/snapcompact/research/mono_prod.py @@ -75,6 +75,26 @@ SHAPES = { "frameSize": SIZE, "frameTokenEstimate": 2900, }, + "8on22-bw": { + "font": "8x13", + "cellWidth": 8, + "cellHeight": 22, + "stretch": False, + "variant": "bw", + "lineRepeat": 1, + "frameSize": SIZE, + "frameTokenEstimate": 2900, + }, + "11on16-bw": { + "font": "8x13", + "cellWidth": 11, + "cellHeight": 16, + "stretch": False, + "variant": "bw", + "lineRepeat": 1, + "frameSize": SIZE, + "frameTokenEstimate": 2900, + }, "6x12-dim": { "font": "6x12", "cellWidth": 6, diff --git a/packages/snapcompact/research/run.py b/packages/snapcompact/research/run.py new file mode 100644 index 000000000..efc570d75 --- /dev/null +++ b/packages/snapcompact/research/run.py @@ -0,0 +1,353 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pillow"] +# /// +"""snapcompact: QA recall per context-compression strategy, over the full SQuAD dev set. + +The corpus (all SQuAD v1.1 dev passages, space-joined; ~1.5M chars) is processed +per condition in chunks sized to that condition's carrying capacity: + + text plain text, fixed 40,716-char chunks (= img-6x10 capacity) + compact agent compaction summary of each text chunk, QA on the summary + handoff agent handoff document of each text chunk, QA on the summary + img-<font>-<v> one 1568x1568 image per chunk; font in {8x13,6x10,5x8,5x7, + 4x6tt,4x5tt}, render variant v in {color,zebra,bw} + +Per chunk, up to --qpc questions are sampled (seeded, evenly spread across the +chunk so answers land at every image row band; pos_rel is recorded for position +analysis). Scoring is official SQuAD EM/F1. Responses are cached by payload +hash, so interrupted runs resume for free. + +Usage examples: + uv run run.py # default condition set, full corpus + uv run run.py --limit-chars 200000 # quick pass on a corpus prefix + uv run run.py --conditions img-6x10-bw # one condition + uv run run.py --report # re-print tables from cache, no API + +Key: ANTHROPIC_API_KEY from ~/.env (last assignment wins). +""" + +import argparse +import hashlib +import json +import re +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +def find_agent_prompts() -> Path: + for parent in HERE.parents: + for candidate in ( + parent / "packages" / "agent" / "src" / "compaction" / "prompts", + parent / "agent" / "src" / "compaction" / "prompts", + ): + if candidate.exists(): + return candidate + raise FileNotFoundError("Could not find agent compaction prompts") + + +sys.path.insert(0, str(HERE)) + +import squad # noqa: E402 +from anthropic_api import complete, image_block, load_api_key # noqa: E402 +from bdf import VARIANTS, FontCfg, capacity, render # noqa: E402 + +AGENT_PROMPTS = find_agent_prompts() +CACHE = HERE / ".cache" +QA_CACHE = CACHE / "qa" +RESULTS = HERE / "results" + +FONTS = { + "8x13": FontCfg("8x13", "8x13", 8, 13), + "7x13": FontCfg("7x13", "7x13", 7, 13), + "6x12": FontCfg("6x12", "6x12", 6, 12), + "6x10": FontCfg("6x10", "6x10", 6, 10), + "6x9": FontCfg("6x9", "6x9", 6, 9), + # Anisotropic squashes: same glyphs, tighter grid. Crop flavor (6x6/6x8/3x10): + # next row's band paints over the vertical overhang, halved advance fuses ink. + # Stretch flavor (*s): rasterize native 6x10, Lanczos-resize to the target cell. + "6x8s": FontCfg("6x8s", "6x10", 6, 8, native=(6, 10)), + "6x6s": FontCfg("6x6s", "6x10", 6, 6, native=(6, 10)), + "3x10s": FontCfg("3x10s", "6x10", 3, 10, native=(6, 10)), + # Human-designed square cell: unscii-8 native, and stretched to a 6x6 cell. + "8x8u": FontCfg("8x8u", "unscii-8", 8, 8), + "6x6u": FontCfg("6x6u", "unscii-8", 6, 6, native=(8, 8)), + "6x8": FontCfg("6x8", "6x9", 6, 8, ascent=7), + "6x6": FontCfg("6x6", "6x9", 6, 6, ascent=6), + "3x10": FontCfg("3x10", "6x10", 3, 10), + # Redundancy coding: every line rendered twice, duplicate on a pale highlight. + "8x8r": FontCfg("8x8r", "unscii-8", 8, 8, repeat=2), + # Downsample-survivors: unscii-8 upscaled so gateway resizes leave legible glyphs. + "12x12u": FontCfg("12x12u", "unscii-8", 12, 12, native=(8, 8)), + "16x16u": FontCfg("16x16u", "unscii-8", 16, 16, native=(8, 8)), + "5x8": FontCfg("5x8", "5x8", 5, 8), + "5x7": FontCfg("5x7", "5x7", 5, 7), + "4x6tt": FontCfg("4x6tt", "tom-thumb", 4, 6, ascent=5), + "4x5tt": FontCfg("4x5tt", "tom-thumb", 4, 5, ascent=5), +} +TEXT_CHUNK = 40716 # = img-6x10 capacity; keeps text/summary chunks comparable +DEFAULT_CONDITIONS = ( + "text,compact,handoff," + "img-8x13-color,img-6x10-color,img-5x8-color,img-5x7-color," + "img-6x10-zebra,img-5x8-zebra,img-6x10-bw,img-5x8-bw" +) + + +def sha8(*parts: str) -> str: + return hashlib.sha1("\x00".join(parts).encode()).hexdigest()[:8] + + +def load_prompt(name: str) -> str: + return (HERE / "prompts" / name).read_text() + + +def agent_prompt(name: str) -> str: + text = (AGENT_PROMPTS / name).read_text() + # Drop unused Handlebars conditionals (no custom focus in this eval). + return re.sub(r"\{\{#if .*?\{\{/if\}\}\n?", "", text, flags=re.DOTALL) + + +def cached_complete(api_key: str, model: str, messages: list[dict], fresh: bool, **kw) -> tuple[str, dict]: + """complete() with response caching keyed on the full request payload. + + Truncated responses (stop_reason == max_tokens) are never cached and never + served from cache, so re-runs with a larger budget repair them. + """ + key = sha8(model, kw.get("effort") or "", json.dumps(messages, sort_keys=True)) + path = QA_CACHE / f"{key}.json" + if path.exists() and not fresh: + hit = json.loads(path.read_text()) + if hit.get("stop") != "max_tokens" and hit["text"]: + return hit["text"], hit["usage"] + text, usage, stop = complete(api_key, model, messages, **kw) + if stop == "max_tokens": + print(f" WARN truncated response (stop=max_tokens), not cached: {key}") + else: + path.write_text(json.dumps({"text": text, "usage": usage, "stop": stop})) + return text, usage + + +def parse_condition(name: str) -> dict: + if name in ("text", "compact", "handoff"): + return {"name": name, "kind": name} + m = re.fullmatch(r"img-([a-z0-9]+)-([a-z-]+)", name) + if not m or m.group(1) not in FONTS or m.group(2) not in VARIANTS: + raise SystemExit(f"bad condition {name!r}; expected text|compact|handoff|img-<font>-<variant>") + return {"name": name, "kind": "image", "font": FONTS[m.group(1)], "variant": m.group(2)} + + +def run_chunk(cond: dict, start: int, end: int, ctx_args: dict) -> list[dict]: + """Execute one (condition, chunk) task; returns per-question records.""" + args, flow, paras, offsets, api_key = ( + ctx_args["args"], + ctx_args["flow"], + ctx_args["paras"], + ctx_args["offsets"], + ctx_args["api_key"], + ) + questions = squad.sample_chunk_questions(paras, offsets, start, end, args.qpc, args.seed) + if not questions: + return [] + chunk_text = flow[start:end] + usage_rows: list[tuple[str, dict]] = [] + + png = cols = rows = None + context = chunk_text + if cond["kind"] == "image": + salt = ("dimv2",) if cond["variant"] == "dim" else () # cache-bust pre-fix sticky-fg dim renders + png = CACHE / f"img-{cond['font'].name}-{cond['variant']}-{sha8(chunk_text, str(args.size), *salt)}.png" + if not png.exists(): + render(chunk_text, cond["font"], CACHE, args.size, cond["variant"]).save(png) + cols, rows, _ = capacity(cond["font"], args.size) + elif cond["kind"] in ("compact", "handoff"): + prompt_file = {"compact": "compaction-summary.md", "handoff": "handoff-document.md"}[cond["kind"]] + gen_messages = [ + {"role": "user", "content": load_prompt("session-frame.md").format(context=chunk_text)}, + {"role": "assistant", "content": "Noted. I have read the passages and will keep them in mind."}, + {"role": "user", "content": agent_prompt(prompt_file)}, + ] + context, gen_usage = cached_complete( + api_key, args.model, gen_messages, args.fresh, + system=agent_prompt("summarization-system.md"), max_tokens=4096, + ) + usage_rows.append(("summarize", gen_usage)) + + use_cache = args.cache == "on" or (args.cache == "auto" and args.qpb > 0) + batch_size = args.qpb or len(questions) + answers: list[str] = [] + for b in range(0, len(questions), batch_size): + batch = questions[b : b + batch_size] + q_block = "\n".join(f"{i + 1}. {q['q']}" for i, q in enumerate(batch)) + if cond["kind"] == "image": + carrier = image_block(png) + preamble = {"type": "text", "text": load_prompt("qa-image.md").format(cols=cols, rows=rows)} + else: + carrier = {"type": "text", "text": load_prompt("qa-text.md").format(context=context)} + preamble = None + if use_cache: + carrier["cache_control"] = {"type": "ephemeral"} + content = ([preamble] if preamble else []) + [carrier, {"type": "text", "text": q_block}] + messages = [{"role": "user", "content": content}] + text, usage = cached_complete( + api_key, args.model, messages, args.fresh, max_tokens=args.max_tokens, effort=args.effort + ) + usage_rows.append(("qa", usage)) + answers.extend(squad.parse_numbered(text, len(batch))) + records = [] + for q, a in zip(questions, answers): + records.append( + { + "cond": cond["name"], + "chunk": start, + "pos_rel": q["pos_rel"], + "q": q["q"], + "answer": a, + "golds": q["golds"], + "em": squad.exact_match(a, q["golds"]), + "f1": squad.f1(a, q["golds"]), + "abstained": "unreadable" in a.lower(), + } + ) + # Attach token usage to the first record of the chunk (avoids double counting). + records[0]["usage"] = [ + { + "phase": phase, + "in": u.get("input_tokens", 0), + "out": u.get("output_tokens", 0), + "cache_w": u.get("cache_creation_input_tokens", 0), + "cache_r": u.get("cache_read_input_tokens", 0), + } + for phase, u in usage_rows + ] + return records + + +def aggregate(name: str, records: list[dict], price_in: float, price_out: float) -> dict: + n = len(records) + f1s = [r["f1"] for r in records] + mean_f1 = sum(f1s) / n + se = (sum((x - mean_f1) ** 2 for x in f1s) / (n * (n - 1))) ** 0.5 if n > 1 else 0.0 + usages = [u for r in records if "usage" in r for u in r["usage"]] + tok_in = sum(u["in"] for u in usages) + tok_out = sum(u["out"] for u in usages) + cache_w = sum(u.get("cache_w", 0) for u in usages) + cache_r = sum(u.get("cache_r", 0) for u in usages) + quart = [] + for lo, hi in ((0.0, 0.25), (0.25, 0.5), (0.5, 0.75), (0.75, 1.01)): + qs = [r["f1"] for r in records if lo <= r["pos_rel"] < hi] + quart.append(sum(qs) / len(qs) if qs else None) + return { + "name": name, + "n": n, + "em": sum(r["em"] for r in records) / n, + "f1": mean_f1, + "f1_se": se, + "abstained": sum(r["abstained"] for r in records), + "tokens_in": tok_in, + "tokens_out": tok_out, + "cache_w": cache_w, + "cache_r": cache_r, + # Anthropic pricing: cache write 1.25x input, cache read 0.1x input (5m TTL). + "cost_usd": (tok_in + 1.25 * cache_w + 0.1 * cache_r) / 1e6 * price_in + tok_out / 1e6 * price_out, + "f1_by_quartile": quart, + } + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--model", default="claude-fable-5") + ap.add_argument("--conditions", default=DEFAULT_CONDITIONS) + ap.add_argument("--qpc", type=int, default=30, help="questions sampled per chunk") + ap.add_argument("--qpb", type=int, default=0, help="questions per API call (batches the chunk); 0 = all at once") + ap.add_argument("--cache", choices=["auto", "on", "off"], default="auto", + help="prompt-cache the carrier block; auto = on when --qpb is set") + ap.add_argument("--max-tokens", type=int, default=8192, help="output budget per QA call (incl. thinking)") + ap.add_argument("--effort", choices=["low", "medium", "high", "xhigh", "max"], default=None, + help="adaptive-thinking effort for QA calls; default = provider default") + ap.add_argument("--seed", type=int, default=42) + ap.add_argument("--size", type=int, default=1568) + ap.add_argument("--workers", type=int, default=4) + ap.add_argument("--limit-chars", type=int, default=0, help="cap corpus size; 0 = full dev set") + ap.add_argument("--limit-paras", type=int, default=0, help="cap corpus to first N passages; 0 = all") + ap.add_argument("--fresh", action="store_true", help="ignore cached responses") + ap.add_argument("--report", action="store_true", help="aggregate cached records only; no API calls") + ap.add_argument("--price-in", type=float, default=10.0, help="$ per 1M input tokens") + ap.add_argument("--price-out", type=float, default=50.0, help="$ per 1M output tokens") + ap.add_argument("--env", default="~/.env") + args = ap.parse_args() + + CACHE.mkdir(exist_ok=True) + QA_CACHE.mkdir(exist_ok=True) + scope = f"p{args.limit_paras}" if args.limit_paras else (args.limit_chars or "full") + tag = "".join( + [ + f"-qpb{args.qpb}" if args.qpb else "", + f"-s{args.size}" if args.size != 1568 else "", + f"-e{args.effort}" if args.effort else "", + ] + ) + run_dir = RESULTS / f"{args.model}-seed{args.seed}-qpc{args.qpc}-{scope}{tag}" + run_dir.mkdir(parents=True, exist_ok=True) + + paras = squad.load_paragraphs(CACHE) + if args.limit_paras: + paras = paras[: args.limit_paras] + flow, offsets = squad.build_flow(paras, args.limit_chars or None) + conditions = [parse_condition(c.strip()) for c in args.conditions.split(",") if c.strip()] + + tasks: list[tuple[dict, int, int]] = [] + for cond in conditions: + budget = capacity(cond["font"], args.size)[2] if cond["kind"] == "image" else TEXT_CHUNK + for start in range(0, len(flow), budget): + tasks.append((cond, start, min(start + budget, len(flow)))) + calls = len(tasks) + sum(1 for c, *_ in tasks if c["kind"] in ("compact", "handoff")) + print( + f"corpus={len(flow):,} chars ({len(offsets):,} passages), {len(conditions)} conditions, " + f"{len(tasks)} chunks, <= {calls} API calls, qpc={args.qpc}, model={args.model}" + ) + + api_key = "" if args.report else load_api_key(args.env) + ctx_args = {"args": args, "flow": flow, "paras": paras, "offsets": offsets, "api_key": api_key} + records: list[dict] = [] + done = 0 + with ThreadPoolExecutor(args.workers) as pool: + futures = [pool.submit(run_chunk, cond, start, end, ctx_args) for cond, start, end in tasks] + for fut in futures: + records.extend(fut.result()) + done += 1 + if done % 20 == 0: + print(f" {done}/{len(tasks)} chunks", flush=True) + + with (run_dir / "records.jsonl").open("w") as fh: + for r in records: + fh.write(json.dumps(r) + "\n") + + rows = [ + aggregate(cond["name"], [r for r in records if r["cond"] == cond["name"]], args.price_in, args.price_out) + for cond in conditions + if any(r["cond"] == cond["name"] for r in records) + ] + rows.sort(key=lambda r: -r["f1"]) + (run_dir / "summary.json").write_text(json.dumps({"args": vars(args), "rows": rows}, indent=1)) + + hdr = ( + f"{'condition':<15}{'n':>6}{'EM':>7}{'F1':>7}{'±se':>6}{'abst':>6}" + f"{'in tok':>10}{'cache w':>9}{'cache r':>9}{'out tok':>9}{'$':>7}" + ) + print("\n" + hdr + "\n" + "-" * len(hdr)) + for r in rows: + print( + f"{r['name']:<15}{r['n']:>6}{r['em']:>7.3f}{r['f1']:>7.3f}{r['f1_se']:>6.3f}{r['abstained']:>6}" + f"{r['tokens_in']:>10,}{r['cache_w']:>9,}{r['cache_r']:>9,}{r['tokens_out']:>9,}{r['cost_usd']:>7.2f}" + ) + print(f"\n{'condition':<15} F1 by position quartile (Q1..Q4)") + for r in rows: + cells = " ".join(" - " if q is None else f"{q:.3f}" for q in r["f1_by_quartile"]) + print(f"{r['name']:<15} {cells}") + print(f"\nresults -> {run_dir}/") + + +if __name__ == "__main__": + main() diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 348e1a14f..dce081240 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -31,11 +31,12 @@ * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is * area-proportional, so resolution cannot improve chars/$ — 1568 stays. * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent. - * - **Unknown providers** default to the Anthropic shape. `providerImageBudget` - * still caps per-request images per provider so inline imaging cannot flood a - * request with attachments, but the old OpenRouter-specific 8-image cap is - * gone; routers now use the same permissive budget as direct Anthropic/Claude - * lines unless configured otherwise upstream. + * - **Unknown providers** default to `8on22-bw` with Anthropic-style + * visual-token area billing. `providerImageBudget` still caps per-request + * images per provider so inline imaging cannot flood a request with + * attachments, but the old OpenRouter-specific 8-image cap is gone; routers + * now use the same permissive budget as direct Anthropic/Claude lines unless + * configured otherwise upstream. * * The whole pass is local and deterministic — no LLM call, no API key, no * latency beyond rendering. Rasterization and PNG encoding happen in native @@ -201,10 +202,13 @@ export function isShapeVariantName(value: unknown): value is ShapeVariantName { } /** Provider families with distinct image billing. */ -type BillingFamily = "anthropic" | "google" | "openai"; +type BillingFamily = "anthropic" | "google" | "openai" | "unknown"; function billingFamily(api?: Api): BillingFamily { switch (api) { + case "anthropic-messages": + case "bedrock-converse-stream": + return "anthropic"; case "openai-completions": case "openai-responses": case "openai-codex-responses": @@ -215,9 +219,8 @@ function billingFamily(api?: Api): BillingFamily { case "google-vertex": return "google"; default: - // anthropic-messages, bedrock-converse-stream, and anything unknown - // share Anthropic's pixel-area pricing as the safe ceiling. - return "anthropic"; + // Unknown APIs share Anthropic's pixel-area pricing as the safe ceiling. + return "unknown"; } } @@ -305,6 +308,7 @@ const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = { anthropic: "11on16-bw", google: "8on22-bw", openai: "8on22-bw", + unknown: "8on22-bw", }; /** Denser companion variant per family for the foveated archive middle: same @@ -315,12 +319,14 @@ const FAMILY_VARIANT_LOW: Record<BillingFamily, ShapeVariantName> = { anthropic: "8on16-bw", google: "8on16-bw", openai: "8on16-bw", + unknown: "8on16-bw", }; const FAMILY_SHAPE: Record<BillingFamily, Shape> = { anthropic: SHAPES.anthropic, google: SHAPES.google, openai: SHAPES.openai, + unknown: priceShape(SHAPE_VARIANTS["8on22-bw"], "unknown"), }; /** One model line's ideal format: variant plus an optional frame-size @@ -348,9 +354,9 @@ const MODEL_VARIANTS: readonly (readonly [RegExp, IdealShape])[] = [ [/gemini/i, { variant: "8on22-bw", frameSize: 2048 }], // gpt-5.5 patch billing is area-proportional; 1568 is already optimal. [/gpt|codex/i, { variant: "8on22-bw" }], - // kimi's image processor downscales past 1792px (64×64 28px patches); - // 1568 wins on chars/$ and reads at f1 .973 (≤8 frames per request). - [/kimi/i, { variant: "8on16-bw" }], + // kimi-k3 chunked bench: `8on22-bw` scored f1 .915 @ $0.66 vs .813 on `8on16-bw` ($0.70); + // 1568 wins on chars/$ (image processor downscales past 1792px). + [/kimi/i, { variant: "8on22-bw" }], // glm-4.6v .780 mono via direct vendor routing. [/glm/i, { variant: "8on16-bw" }], ]; diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 559cc2209..fd6734bd4 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -267,9 +267,11 @@ describe("shape resolution", () => { expect(snapcompact.resolveShape({ api: "openai-responses" })).toBe(snapcompact.SHAPES.openai); expect(snapcompact.resolveShape({ api: "azure-openai-responses" })).toBe(snapcompact.SHAPES.openai); expect(snapcompact.resolveShape({ api: "google-generative-ai" })).toBe(snapcompact.SHAPES.google); - // Unknown and absent APIs fall back to the Anthropic family default. - expect(snapcompact.resolveShape({ api: "some-future-api" })).toBe(snapcompact.SHAPES.anthropic); - expect(snapcompact.resolveShape(undefined)).toBe(snapcompact.SHAPES.anthropic); + // Unknown and absent APIs fall back to the unknown family default (8on22-bw with Anthropic token billing). + const unknownFallback = snapcompact.resolveShape({ api: "some-future-api" }); + expect(unknownFallback.cellHeight).toBe(22); + expect(unknownFallback.variant).toBe("bw"); + expect(snapcompact.resolveShape(undefined)).toEqual(unknownFallback); }); it("detects the ideal shape from the model id across gateways", () => { @@ -309,11 +311,11 @@ describe("shape resolution", () => { expect(gemini.cellHeight).toBe(22); // extra leading expect(gemini.frameTokenEstimate).toBe(1120); - // Measured openai-compat readers keep their own validated `8on16-bw` - // geometry (not the family's leading default), at the gateway's billing. - const kimiShape = snapcompact.resolveShape({ api: "openai-completions" }, "8on16-bw"); + // Kimi models resolve to 8on22-bw; GLM keeps 8on16-bw. + const kimiShape = snapcompact.resolveShape({ api: "openai-completions" }, "8on22-bw"); + const glmShape = snapcompact.resolveShape({ api: "openai-completions" }, "8on16-bw"); expect(snapcompact.resolveShape({ api: "openai-completions", id: "moonshotai/kimi-k2.6" })).toEqual(kimiShape); - expect(snapcompact.resolveShape({ api: "openai-completions", id: "z-ai/glm-4.6v" })).toEqual(kimiShape); + expect(snapcompact.resolveShape({ api: "openai-completions", id: "z-ai/glm-4.6v" })).toEqual(glmShape); // Unmeasured model ids fall back to the API family default object. expect(snapcompact.resolveShape({ api: "openai-completions", id: "qwen/qwen3-vl" })).toBe( @@ -914,7 +916,7 @@ describe("compact", () => { const hugeText = `HEAD sentinel. ${"Important fact number one. ".repeat(1000)}TAIL sentinel.`; const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage(hugeText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, + { model: { api: "anthropic-messages" }, frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); expect(archive?.frames).toHaveLength(7); diff --git a/packages/stats/package.json b/packages/stats/package.json index d732877c6..3a0c73edd 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "17.2.6", + "version": "17.2.8", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json index 542a8b6e9..1041b8abe 100644 --- a/packages/swarm-extension/package.json +++ b/packages/swarm-extension/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/swarm-extension", - "version": "17.2.6", + "version": "17.2.8", "description": "Swarm orchestration extension for omp", "homepage": "https://omp.sh", "author": "Derek Rynd", 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<string, number> = { 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<string, string> = { - "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<string, string> = { 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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g; +const REMOVAL_START_RE = + /[\x00-\x08\x0B-\x1F\x7F-\x9F]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g; - // Variant C: regex only matches real removal starts, not valid surrogate pairs. - function removalStartSanitizeText(text: string): string { - REMOVAL_START_RE.lastIndex = 0; - let m = REMOVAL_START_RE.exec(text); - if (m === null) return text; - const len = text.length; - let out = ""; - let last = 0; - 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; - REMOVAL_START_RE.lastIndex = last; - m = REMOVAL_START_RE.exec(text); - } - if (last < len) out += text.slice(last); - return out; - } +// Variant C: regex only matches real removal starts, not valid surrogate pairs. +function removalStartSanitizeText(text: string): string { + REMOVAL_START_RE.lastIndex = 0; + let m = REMOVAL_START_RE.exec(text); + if (m === null) return text; + const len = text.length; + let out = ""; + let last = 0; + 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; + REMOVAL_START_RE.lastIndex = last; + m = REMOVAL_START_RE.exec(text); + } + if (last < len) out += text.slice(last); + return out; +} - const CONTROL_RE_G = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g; +const CONTROL_RE_G = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g; - // Variant D: avoid valid-surrogate matches when the string is well-formed. - function wellFormedControlSanitizeText(text: string): string { - if (!text.isWellFormed()) return skipRunSanitizeText(text); - CONTROL_RE_G.lastIndex = 0; - let m = CONTROL_RE_G.exec(text); - if (m === null) return text; - const len = text.length; - let out = ""; - let last = 0; - 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; - } +// Variant D: avoid valid-surrogate matches when the string is well-formed. +function wellFormedControlSanitizeText(text: string): string { + if (!text.isWellFormed()) return skipRunSanitizeText(text); + CONTROL_RE_G.lastIndex = 0; + let m = CONTROL_RE_G.exec(text); + if (m === null) return text; + const len = text.length; + let out = ""; + let last = 0; + 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; +} - // 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); - } +// 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/packages/tui/package.json b/packages/tui/package.json index 1be723392..1a4c7e81d 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "17.2.6", + "version": "17.2.8", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/package.json b/packages/utils/package.json index 5e82793db..80822c8f0 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "17.2.6", + "version": "17.2.8", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/wire/package.json b/packages/wire/package.json index 4a39138dd..0a7bf77ff 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-wire", - "version": "17.2.6", + "version": "17.2.8", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", 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<PackageManifest> { +/** Compute (and optionally write) the published manifest for a package. */ +export async function rewriteManifest(pkg: PublishPackage, write: boolean): Promise<PackageManifest> { 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<PackageManifest> { 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<CodingAgentBucket, { label: string; paralle const fastWorkspacePackages = [ "packages/hashline", "packages/wire", + "packages/omptype", "packages/utils", "packages/catalog", "packages/ai", diff --git a/scripts/fix-dts-extensions.ts b/scripts/fix-emit-extensions.ts old mode 100755 new mode 100644 similarity index 55% rename from scripts/fix-dts-extensions.ts rename to scripts/fix-emit-extensions.ts index be6c604e0..79e807e06 --- a/scripts/fix-dts-extensions.ts +++ b/scripts/fix-emit-extensions.ts @@ -1,24 +1,26 @@ #!/usr/bin/env bun /** - * Rewrite extensionless relative specifiers in emitted `.d.ts` to explicit - * `.js` extensions so the published declarations resolve under + * Rewrite extensionless relative specifiers in emitted `.d.ts`/`.js` files to + * explicit `.js` extensions so published output resolves under Node ESM and * `moduleResolution: "node16" | "nodenext"`. * * The workspace type-checks under `moduleResolution: "Bundler"` * (`tsconfig.base.json`), which permits extensionless relative imports. `tsgo` * therefore emits `export * from "./sdk"` / `import … from "./modes/components"` - * into `dist/types`. A downstream consumer on `nodenext` (the modern default) - * then can't resolve the barrel — every relative re-export is a `TS2834`, which - * cascades into `TS2305` "no exported member" on the whole package root. + * into `dist/types` (declarations) and `dist/js` (transpiled runtime). A + * downstream consumer on `nodenext` then can't resolve the barrel — every + * relative re-export is a `TS2834`, which cascades into `TS2305` "no exported + * member" on the whole package root — and Node refuses the extensionless + * import outright at runtime. * * This post-emit pass appends the correct extension to each **relative** * specifier, resolving it against the emitted tree: - * `./sdk` → `./sdk.js` (sibling `sdk.d.ts`) + * `./sdk` → `./sdk.js` (sibling `sdk.d.ts` / `sdk.js`) * `./modes/components` → `./modes/components/index.js` (directory barrel) * Bare (`@scope/pkg`, `zod/v4`) and already-suffixed (`.js`, `.json`) * specifiers are left untouched. * - * Applied by `ci-release-publish.ts` after `tsgo -p tsconfig.publish.json`. + * Applied by `ci-release-publish.ts` after each `tsgo -p tsconfig.publish*.json`. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -28,19 +30,24 @@ import * as path from "node:path"; // Captures the quote-enclosed module string; we filter to relative ones below. const SPECIFIER_RE = /(\b(?:from|import|module)\b\s*(?:\(\s*)?)("|')(\.[^"']*)(\2)/g; +/** Emitted-file kind: declaration or transpiled-runtime output. */ +export type EmitExt = ".d.ts" | ".js"; + /** Resolve an extensionless relative specifier to its `.js` runtime form, - * given the directory of the importing `.d.ts`. Returns null to leave as-is. */ -export async function resolveDtsSpecifier(fromDir: string, spec: string): Promise<string | null> { + * 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<string | null> { // 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<boolean> { } } -/** Rewrite one `.d.ts` file in place. Returns the number of specifiers changed. */ -export async function fixDtsFile(filePath: string): Promise<number> { +/** Rewrite one emitted file in place. Returns the number of specifiers changed. */ +export async function fixEmitFile(filePath: string, ext: EmitExt): Promise<number> { 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<number> { 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<number> { 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 <dist/types dir>"); + const ext = (process.argv[3] ?? ".d.ts") as EmitExt; + if (!target || (ext !== ".d.ts" && ext !== ".js")) { + console.error("usage: fix-emit-extensions.ts <emitted dir> [.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 diff --git a/scripts/install.sh b/scripts/install.sh index ae40e0161..29e7adbff 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -267,6 +267,28 @@ install_binary() { echo "Downloading ${BINARY}..." curl -fsSL --connect-timeout 10 --speed-limit 1024 --speed-time 30 "$BINARY_URL" -o "${INSTALL_DIR}/omp" chmod +x "${INSTALL_DIR}/omp" + + # Verify the freshly installed binary can actually start before reporting + # success. Bun's musl-target binaries link libstdc++/libgcc dynamically, + # which stock Alpine/musl systems do not ship, so the download succeeds while + # the binary exits 127 with relocation errors. Never claim success for a + # binary that cannot run. + if ! SMOKE_OUTPUT="$("${INSTALL_DIR}/omp" --version 2>&1)"; then + echo "" + echo "✗ omp was downloaded to ${INSTALL_DIR}/omp but cannot start:" + echo "$SMOKE_OUTPUT" | sed 's/^/ /' + if [ "$PLATFORM" = "linux-musl" ]; then + echo "" + echo "The musl build links libstdc++/libgcc dynamically. Install them, then re-run 'omp':" + if command -v apk >/dev/null 2>&1; then + echo " apk add libstdc++ libgcc" + else + echo " (install the libstdc++ and libgcc runtime packages for your distro)" + fi + fi + exit 1 + fi + echo "" echo "✓ Installed omp to ${INSTALL_DIR}/omp"