diff --git a/README.md b/README.md index 4f8f0b78..1302b114 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ To see a complex example of a CLI agent built with aeye - `npm i -g @aeye/cletus` and run `cletus`! +For a higher-level "build with types" experience, check out **[@aeye/gin](./packages/gin)** (a JSON-typed, executable program language for LLMs) and **[@aeye/ginny](./packages/ginny)** (a CLI that turns natural-language requests into validated gin programs) — `npm i -g @aeye/ginny` and run `ginny`. + ```ts import { AI } from '@aeye/ai'; import { OpenAIProvider } from '@aeye/openai'; @@ -227,6 +229,35 @@ npm install @aeye/aws - Text embeddings (Amazon Titan) - Automatic AWS credential discovery +### Higher-Level Packages + +#### [@aeye/gin](./packages/gin) +A JSON-based programming language and type system designed for LLMs to author, validate, and execute typed programs at runtime. Gives the model a real type system (generics, structural compatibility, extension-based inheritance) and an expression language serialized as plain JSON — programs round-trip through `JSON.stringify` / `JSON.parse`, can be introspected and validated without running them, and execute in-process against a pluggable registry of native functions. + +```bash +npm install @aeye/gin zod +``` + +**Features:** +- Typed expressions (`get`, `set`, `define`, `loop`, `if`, `switch`, `lambda`, `flow`, `native`, …) authored as JSON +- Static `validate()` catches unknown vars, prop / type mismatches, out-of-place flow before execution +- Generics with constraints (not defaults), structural type compatibility, type augmentation via `registry.augment(...)` +- Sequential and parallel loops over lists, maps, objs, text, num — plus dynamic (bool while-loop) iteration that composes with parallelism + +#### [@aeye/ginny](./packages/ginny) +CLI agent that turns natural-language requests into executable gin programs. Multi-prompt orchestration (programmer → designer → architect → researcher → DBA) drafts, validates, and persists reusable typed functions and variables to disk, with a path-callable native fn surface (`fns.fetch`, `fns.llm`, `fns.log`, `fns.ask`) and optional Tavily-powered web research. + +```bash +npm install -g @aeye/ginny +ginny +``` + +**Features:** +- REPL with conversation history, ESC-to-interrupt, Ctrl+C exit +- Per-prompt model overrides via `GIN__MODEL` env vars (programmer, researcher, architect, designer, dba, llm) +- Fn / type / var catalog persisted as JSON under `./fns`, `./types`, `./vars` for reuse across sessions +- Works with any provider configured for `@aeye/ai` — OpenAI, OpenRouter, AWS Bedrock; web research via Tavily + ## Usage Examples ### Chat Completion @@ -766,6 +797,8 @@ aeye/ │ ├── openrouter/ # OpenRouter provider │ ├── replicate/ # Replicate provider │ ├── aws/ # AWS Bedrock provider +│ ├── gin/ # JSON-typed program language for LLMs +│ ├── ginny/ # CLI agent that authors gin programs │ └── cletus/ # Example CLI agent ├── package.json # Root package configuration └── tsconfig.json # TypeScript configuration diff --git a/package-lock.json b/package-lock.json index 217d8c83..94633e53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -987,6 +987,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -1295,6 +1296,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4530,6 +4532,7 @@ "version": "2.10.13", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", @@ -4551,6 +4554,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4560,6 +4564,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4575,6 +4580,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -4589,6 +4595,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4598,6 +4605,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4610,6 +4618,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4624,6 +4633,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4636,6 +4646,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -4653,6 +4664,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "devOptional": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -6097,6 +6109,7 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -7293,6 +7306,7 @@ "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -7372,6 +7386,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -7544,6 +7559,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -7665,6 +7681,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7822,6 +7839,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "devOptional": true, "license": "MIT", "engines": { "node": "*" @@ -7884,6 +7902,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -8158,6 +8177,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -8171,6 +8191,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -8558,6 +8579,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "devOptional": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -8584,12 +8606,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10181,6 +10205,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14" @@ -10319,6 +10344,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "devOptional": true, "license": "MIT", "dependencies": { "ast-types": "^0.13.4", @@ -10399,6 +10425,7 @@ "version": "0.0.1521046", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/diff": { @@ -10581,6 +10608,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "devOptional": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -10616,6 +10644,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -10638,6 +10667,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -10777,6 +10807,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -10798,6 +10829,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "devOptional": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -10811,6 +10843,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -10840,6 +10873,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -10876,6 +10910,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -10962,6 +10997,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -10982,6 +11018,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "devOptional": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -11004,6 +11041,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -11087,6 +11125,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "devOptional": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -11433,6 +11472,7 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "devOptional": true, "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", @@ -11942,6 +11982,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -12018,6 +12059,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -12034,6 +12076,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -12299,6 +12342,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 12" @@ -12345,6 +12389,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, "license": "MIT" }, "node_modules/is-buffer": { @@ -13543,6 +13588,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "devOptional": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -13576,6 +13622,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -14054,6 +14101,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true, "license": "MIT" }, "node_modules/load-tsconfig": { @@ -15364,6 +15412,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "devOptional": true, "license": "MIT" }, "node_modules/mkdirp": { @@ -15470,6 +15519,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -15604,6 +15654,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -15886,6 +15937,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "devOptional": true, "license": "MIT", "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", @@ -15905,6 +15957,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "devOptional": true, "license": "MIT", "dependencies": { "degenerator": "^5.0.0", @@ -15938,6 +15991,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -15975,6 +16029,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -16117,12 +16172,14 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -16390,6 +16447,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -16409,6 +16467,7 @@ "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -16424,6 +16483,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "devOptional": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -16434,6 +16494,7 @@ "version": "24.31.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.31.0.tgz", "integrity": "sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -16455,6 +16516,7 @@ "version": "24.31.0", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.31.0.tgz", "integrity": "sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.10.13", @@ -17307,6 +17369,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -17317,6 +17380,7 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "devOptional": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -17331,6 +17395,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -17457,6 +17522,7 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "devOptional": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -17864,6 +17930,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "devOptional": true, "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -17878,6 +17945,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "devOptional": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -17965,6 +18033,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -18891,6 +18960,7 @@ "version": "2.12.0", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "devOptional": true, "license": "MIT" }, "node_modules/typescript": { @@ -19614,6 +19684,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz", "integrity": "sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/which": { @@ -19887,6 +19958,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -20012,6 +20084,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -20074,6 +20147,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "devOptional": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -22162,7 +22236,7 @@ }, "packages/gin": { "name": "@aeye/gin", - "version": "0.1.0", + "version": "0.3.8", "license": "GPL-3.0", "dependencies": { "zod": "^4.1.12" @@ -22185,7 +22259,6 @@ "mammoth": "^1.8.0", "node-html-markdown": "^1.3.0", "pdf-parse": "^1.1.1", - "puppeteer": "^24.0.0", "xlsx": "^0.18.5" }, "bin": { @@ -22195,7 +22268,7 @@ "@aeye/ai": "0.3.8", "@aeye/aws": "0.3.8", "@aeye/core": "0.3.8", - "@aeye/gin": "0.1.0", + "@aeye/gin": "0.3.8", "@aeye/models": "0.3.8", "@aeye/openai": "0.3.8", "@aeye/openrouter": "0.3.8", @@ -22209,6 +22282,9 @@ }, "engines": { "node": ">=18.0.0" + }, + "optionalDependencies": { + "puppeteer": "^24.0.0" } }, "packages/ginny/node_modules/@esbuild/aix-ppc64": { diff --git a/packages/ai/README.md b/packages/ai/README.md index d599d791..c6ebb3c1 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -623,6 +623,32 @@ const ai = AI.with() }); ``` +### Strict Mode + +Tool and Prompt schemas can ride the LLM's grammar-constrained strict mode for guaranteed type-safe output. Mechanics — descriptors, dialects, schema rewriting — live in [`@aeye/core`](../core/README.md#strict-mode). What `@aeye/ai` adds: model selection respects the `strict` flag (selection filters to strict-capable models when `strict: true`, scores them higher when `strict: `). + +Splat the curated `strictSupport` overrides from `@aeye/models` so model selection knows which models actually support strict — without it, every model defaults to lenient even when the underlying API supports strict: + +```typescript +import { models, strictSupport } from '@aeye/models'; + +const ai = AI.with() + .providers({ openai }) + .create({ + models, + modelOverrides: [...strictSupport], + }); +``` + +`strictSupport` is a `ModelOverride[]` you can combine with your own overrides: + +```typescript +modelOverrides: [ + ...strictSupport, + { modelPattern: /gpt-4/, overrides: { tier: 'flagship' } }, +], +``` + ### Custom Providers Extend an existing provider or implement the `Provider` interface from `@aeye/core`: diff --git a/packages/ai/src/__tests__/chat-strict.test.ts b/packages/ai/src/__tests__/chat-strict.test.ts new file mode 100644 index 00000000..f058819e --- /dev/null +++ b/packages/ai/src/__tests__/chat-strict.test.ts @@ -0,0 +1,171 @@ +/** + * Chat API strict tri-state tests. + * + * Verifies that: + * - `tool.strict === true` adds `'toolsStrict'` to REQUIRED (hard filter). + * - `tool.strict === ` adds `'toolsStrict'` to OPTIONAL (preference). + * - `tool.strict === false` adds nothing (neither required nor optional). + * - default-omitted strict (which Tool.compile maps to `1`) lands in OPTIONAL. + * - mixed: at least one `true` → required wins regardless of other tools. + */ + +import z from 'zod'; +import { AI } from '../ai'; +import { ChatAPI } from '../apis/chat'; +import type { ModelCapability, ModelParameter, Request } from '../types'; +import { createMockProvider } from './mocks/provider.mock'; + +// Subclass to expose protected methods for direct testing. +class TestChatAPI extends ChatAPI { + exposedRequired(provided: ModelCapability[], request: Request): ModelCapability[] { + return this.getRequiredCapabilities(provided, request, false); + } + exposedOptional(provided: ModelCapability[], request: Request): ModelCapability[] { + return this.getOptionalCapabilities(provided, request, false); + } + exposedRequiredParams(provided: ModelParameter[], request: Request): ModelParameter[] { + return this.getRequiredParameters(provided, request, false); + } + exposedOptionalParams(provided: ModelParameter[], request: Request): ModelParameter[] { + return this.getOptionalParameters(provided, request, false); + } +} + +function makeChatAPI(): TestChatAPI { + const ai = AI.with().providers({ mock: createMockProvider({ name: 'mock' }) }).create({}); + return new TestChatAPI(ai); +} + +function toolDef(name: string, strict: boolean | number | undefined): Request['tools'][0] { + return { + name, + description: `${name} tool`, + parameters: z.object({ x: z.string() }), + strict, + }; +} + +describe('ChatAPI strict tri-state', () => { + describe('getRequiredCapabilities', () => { + it('adds toolsStrict when any tool has strict === true (hard requirement)', () => { + const api = makeChatAPI(); + const required = api.exposedRequired([], { + messages: [], + tools: [toolDef('a', true), toolDef('b', false)], + }); + expect(required).toContain('toolsStrict'); + }); + + it('does NOT add toolsStrict when no tool has strict === true', () => { + const api = makeChatAPI(); + const required = api.exposedRequired([], { + messages: [], + tools: [toolDef('a', 5), toolDef('b', undefined), toolDef('c', false)], + }); + expect(required).not.toContain('toolsStrict'); + }); + + it('does NOT add toolsStrict when there are no tools', () => { + const api = makeChatAPI(); + const required = api.exposedRequired([], { + messages: [], + }); + expect(required).not.toContain('toolsStrict'); + }); + + it('adds tools capability whenever there are tools', () => { + const api = makeChatAPI(); + const required = api.exposedRequired([], { + messages: [], + tools: [toolDef('a', false)], + }); + expect(required).toContain('tools'); + }); + }); + + describe('getOptionalCapabilities', () => { + it('adds toolsStrict when any tool has numeric strict > 0', () => { + const api = makeChatAPI(); + const optional = api.exposedOptional([], { + messages: [], + tools: [toolDef('a', 5)], + }); + expect(optional).toContain('toolsStrict'); + }); + + it('adds toolsStrict for default-omitted strict (treated as priority 1)', () => { + const api = makeChatAPI(); + const optional = api.exposedOptional([], { + messages: [], + tools: [toolDef('a', undefined)], + }); + expect(optional).toContain('toolsStrict'); + }); + + it('does NOT add toolsStrict when all tools are strict: false', () => { + const api = makeChatAPI(); + const optional = api.exposedOptional([], { + messages: [], + tools: [toolDef('a', false), toolDef('b', false)], + }); + expect(optional).not.toContain('toolsStrict'); + }); + + it('does NOT add toolsStrict when all tools are hard `true` (already required)', () => { + // Hard `true` items already register in REQUIRED — including them in + // OPTIONAL too would be redundant but harmless. Current implementation + // intentionally skips them in optional path. + const api = makeChatAPI(); + const optional = api.exposedOptional([], { + messages: [], + tools: [toolDef('a', true), toolDef('b', true)], + }); + expect(optional).not.toContain('toolsStrict'); + }); + }); + + describe('mixed scenarios', () => { + it('hard `true` on one tool puts toolsStrict in REQUIRED even when others are numeric', () => { + const api = makeChatAPI(); + const required = api.exposedRequired([], { + messages: [], + tools: [toolDef('a', true), toolDef('b', 5), toolDef('c', false)], + }); + expect(required).toContain('toolsStrict'); + }); + + it('all-numeric tools puts toolsStrict in OPTIONAL only', () => { + const api = makeChatAPI(); + const req = api.exposedRequired([], { + messages: [], + tools: [toolDef('a', 5), toolDef('b', 1)], + }); + const opt = api.exposedOptional([], { + messages: [], + tools: [toolDef('a', 5), toolDef('b', 1)], + }); + expect(req).not.toContain('toolsStrict'); + expect(opt).toContain('toolsStrict'); + }); + }); + + describe('parameter mirror', () => { + it("adds 'toolsStrict' to optional parameters for numeric strict", () => { + const api = makeChatAPI(); + const optParams = api.exposedOptionalParams([], { + messages: [], + tools: [toolDef('a', 5)], + }); + expect(optParams).toContain('toolsStrict'); + }); + + it("does NOT add 'toolsStrict' to optional parameters for strict: false", () => { + const api = makeChatAPI(); + const optParams = api.exposedOptionalParams([], { + messages: [], + tools: [toolDef('a', false)], + }); + expect(optParams).not.toContain('toolsStrict'); + }); + }); +}); diff --git a/packages/ai/src/__tests__/mocks/provider.mock.ts b/packages/ai/src/__tests__/mocks/provider.mock.ts index de6f3cdd..32a8dc76 100644 --- a/packages/ai/src/__tests__/mocks/provider.mock.ts +++ b/packages/ai/src/__tests__/mocks/provider.mock.ts @@ -168,7 +168,12 @@ export const createMockProvider = (options?: MockProviderOptions): Provider => { })), model: request.model || `${providerName}-embed`, usage: { - text: { input: request.texts.length * 5, output: 0 }, + // Embedding endpoints report usage under `embeddings` rather than + // `text` — the embed API expects this shape (count + tokens). + embeddings: { + count: request.texts.length, + tokens: request.texts.length * 5, + }, } }; } diff --git a/packages/ai/src/__tests__/strict-support.test.ts b/packages/ai/src/__tests__/strict-support.test.ts new file mode 100644 index 00000000..4fdb8e55 --- /dev/null +++ b/packages/ai/src/__tests__/strict-support.test.ts @@ -0,0 +1,269 @@ +/** + * Strict-mode support tests. + * + * Verifies that: + * - `'toolsStrict'` capability is auto-derived when `resolveStrictFormat` + * returns a family — explicit `strictFormat`, provider name, or id prefix. + * - `getStrictFormat` returns the right descriptor for each (model, requested) combo + * - Optional capability scoring biases selection toward strict-capable models without filtering + * - `strictFormat: 'none'` opts a model out even when provider/id would auto-resolve + */ + +import { + ANTHROPIC_STRICT, + GOOGLE_STRICT, + LENIENT, + OPENAI_STRICT, + registerDescriptor, +} from '@aeye/core'; +import { ModelRegistry, resolveStrictFormat } from '../registry'; +import type { ModelInfo, ModelOverride } from '../types'; +import { createMockProvider } from './mocks/provider.mock'; + +function makeMockModel(provider: string, id: string, overrides: Partial = {}): ModelInfo { + return { + id, + provider, + name: id, + capabilities: new Set(['chat', 'tools']), + tier: 'flagship', + pricing: { text: { input: 1, output: 1 } }, + contextWindow: 8192, + maxOutputTokens: 4096, + ...overrides, + }; +} + +describe('resolveStrictFormat', () => { + it('returns explicit strictFormat when set to a family', () => { + expect(resolveStrictFormat({ id: 'foo', provider: 'aws', strictFormat: 'anthropic' })).toBe('anthropic'); + }); + + it("returns undefined for explicit 'none' even when provider/id would auto-resolve", () => { + expect(resolveStrictFormat({ id: 'gpt-3.5-turbo', provider: 'openai', strictFormat: 'none' })).toBeUndefined(); + }); + + it('falls back to provider name when it matches a family', () => { + expect(resolveStrictFormat({ id: 'gpt-4o', provider: 'openai' })).toBe('openai'); + expect(resolveStrictFormat({ id: 'claude-opus-4', provider: 'anthropic' })).toBe('anthropic'); + expect(resolveStrictFormat({ id: 'gemini-2.5-pro', provider: 'google' })).toBe('google'); + }); + + it('falls back to id prefix before / when provider does not match', () => { + expect(resolveStrictFormat({ id: 'openai/gpt-4o', provider: 'openrouter' })).toBe('openai'); + expect(resolveStrictFormat({ id: 'anthropic/claude-opus-4', provider: 'openrouter' })).toBe('anthropic'); + expect(resolveStrictFormat({ id: 'google/gemini-2.5', provider: 'openrouter' })).toBe('google'); + }); + + it('returns undefined when neither provider nor id prefix matches', () => { + expect(resolveStrictFormat({ id: 'anthropic.claude-opus-4', provider: 'aws' })).toBeUndefined(); + expect(resolveStrictFormat({ id: 'gpt-4o', provider: 'replicate' })).toBeUndefined(); + }); +}); + +describe('Strict-mode capability handling', () => { + describe('applyOverrides auto-derivation', () => { + it("derives 'toolsStrict' capability when explicit strictFormat is set via overrides", async () => { + const provider = createMockProvider({ + name: 'p', + models: [makeMockModel('p', 'capable-model')], + }); + + const overrides: ModelOverride[] = [{ + provider: 'p', + modelId: 'capable-model', + overrides: { strictFormat: 'openai' }, + }]; + + const registry = new ModelRegistry({ p: provider }, overrides); + await registry.refresh(); + + const model = registry.getModel('capable-model'); + expect(model).toBeDefined(); + expect(model!.strictFormat).toBe('openai'); + expect(model!.capabilities.has('toolsStrict')).toBe(true); + }); + + it("does NOT derive 'toolsStrict' just because provider is a family name", async () => { + // Capability is opt-IN: a provider name that auto-resolves to a family + // (via resolveStrictFormat's fallback chain) is NOT enough to mark + // the model strict-capable. The curated table or scraper must set + // `strictFormat` explicitly. This protects legacy models like + // gpt-3.5-turbo from being silently treated as strict. + const provider = createMockProvider({ + name: 'openai', + models: [makeMockModel('openai', 'gpt-3.5-turbo')], + }); + const registry = new ModelRegistry({ openai: provider }); + await registry.refresh(); + + const model = registry.getModel('gpt-3.5-turbo')!; + expect(model.capabilities.has('toolsStrict')).toBe(false); + }); + + it("does NOT derive 'toolsStrict' from id prefix alone", async () => { + // Same principle for OpenRouter-style ids — the prefix lets the + // dialect resolve at runtime, but doesn't itself opt the model in. + const provider = createMockProvider({ + name: 'openrouter', + models: [makeMockModel('openrouter', 'openai/some-future-legacy-model')], + }); + const registry = new ModelRegistry({ openrouter: provider }); + await registry.refresh(); + + const model = registry.getModel('openai/some-future-legacy-model')!; + expect(model.capabilities.has('toolsStrict')).toBe(false); + }); + + it("does NOT derive 'toolsStrict' when provider isn't a family and id has no family prefix", async () => { + const provider = createMockProvider({ + name: 'aws', + models: [makeMockModel('aws', 'anthropic.claude-3-5-sonnet')], + }); + const registry = new ModelRegistry({ aws: provider }); + await registry.refresh(); + + const model = registry.getModel('anthropic.claude-3-5-sonnet')!; + expect(model.capabilities.has('toolsStrict')).toBe(false); + }); + + it("'strictFormat: none' removes 'toolsStrict' that an upstream source had set", async () => { + // Belt-and-suspenders opt-out: if a future scraper incorrectly added + // 'toolsStrict' to a model that doesn't support strict, the curated + // table can clear it. + const provider = createMockProvider({ + name: 'p', + models: [makeMockModel('p', 'wrongly-cap', { + capabilities: new Set(['chat', 'tools', 'toolsStrict']), + })], + }); + const overrides: ModelOverride[] = [{ + provider: 'p', + modelId: 'wrongly-cap', + overrides: { strictFormat: 'none' }, + }]; + const registry = new ModelRegistry({ p: provider }, overrides); + await registry.refresh(); + + const model = registry.getModel('wrongly-cap')!; + expect(model.strictFormat).toBe('none'); + expect(model.capabilities.has('toolsStrict')).toBe(false); + }); + }); + + describe('getStrictFormat', () => { + it('returns LENIENT when requested is false', async () => { + const provider = createMockProvider({ + name: 'p', + models: [makeMockModel('p', 'm', { strictFormat: 'openai' })], + }); + const registry = new ModelRegistry({ p: provider }); + await registry.refresh(); + const model = registry.getModel('m')!; + expect(registry.getStrictFormat(model, false)).toBe(LENIENT); + }); + + it('returns LENIENT when the model has no resolvable family', async () => { + const provider = createMockProvider({ + name: 'aws', + models: [makeMockModel('aws', 'plain-model')], + }); + const registry = new ModelRegistry({ aws: provider }); + await registry.refresh(); + const model = registry.getModel('plain-model')!; + expect(registry.getStrictFormat(model, true)).toBe(LENIENT); + }); + + it('returns the right strict descriptor per resolved family', async () => { + const provider = createMockProvider({ + name: 'p', + models: [ + makeMockModel('p', 'oai', { strictFormat: 'openai' }), + makeMockModel('p', 'ant', { strictFormat: 'anthropic' }), + makeMockModel('p', 'gog', { strictFormat: 'google' }), + ], + }); + const registry = new ModelRegistry({ p: provider }); + await registry.refresh(); + + expect(registry.getStrictFormat(registry.getModel('oai')!, true)).toBe(OPENAI_STRICT); + expect(registry.getStrictFormat(registry.getModel('ant')!, true)).toBe(ANTHROPIC_STRICT); + expect(registry.getStrictFormat(registry.getModel('gog')!, true)).toBe(GOOGLE_STRICT); + }); + }); + + describe('custom descriptor families', () => { + it('resolveStrictFormat finds a custom family registered in @aeye/core', () => { + registerDescriptor({ + ...OPENAI_STRICT, + id: 'cohere-strict', + family: 'cohere', + }); + + // Direct provider name match. + expect(resolveStrictFormat({ id: 'command-r', provider: 'cohere' })).toBe('cohere'); + // ID prefix match. + expect(resolveStrictFormat({ id: 'cohere/command-r', provider: 'openrouter' })).toBe('cohere'); + }); + + it("auto-derives 'toolsStrict' from explicit custom strictFormat", async () => { + registerDescriptor({ + ...OPENAI_STRICT, + id: 'mistral-strict', + family: 'mistral', + }); + + const provider = createMockProvider({ + name: 'p', + models: [makeMockModel('p', 'mistral-large', { strictFormat: 'mistral' })], + }); + const registry = new ModelRegistry({ p: provider }); + await registry.refresh(); + + const model = registry.getModel('mistral-large')!; + expect(model.strictFormat).toBe('mistral'); + expect(model.capabilities.has('toolsStrict')).toBe(true); + // getStrictFormat resolves to the registered descriptor. + const desc = registry.getStrictFormat(model, true); + expect(desc.id).toBe('mistral-strict'); + }); + }); + + describe("'toolsStrict' as an optional preference", () => { + it('does NOT filter out non-strict models when set as optional', async () => { + const provider = createMockProvider({ + name: 'p', + models: [ + makeMockModel('p', 'strict-capable', { strictFormat: 'openai' }), + makeMockModel('p', 'plain'), + ], + }); + const registry = new ModelRegistry({ p: provider }); + await registry.refresh(); + + const selection = registry.selectModel({ + required: ['chat'], + optional: ['toolsStrict'], + }); + + expect(selection).toBeDefined(); + }); + + it('filters out non-strict models when toolsStrict is REQUIRED', async () => { + const provider = createMockProvider({ + name: 'aws', + models: [ + makeMockModel('aws', 'plain'), + ], + }); + const registry = new ModelRegistry({ aws: provider }); + await registry.refresh(); + + const selection = registry.selectModel({ + required: ['chat', 'toolsStrict'], + }); + + expect(selection).toBeUndefined(); + }); + }); +}); diff --git a/packages/ai/src/apis/base.ts b/packages/ai/src/apis/base.ts index 705aec1f..f0ead656 100644 --- a/packages/ai/src/apis/base.ts +++ b/packages/ai/src/apis/base.ts @@ -95,11 +95,20 @@ export abstract class BaseAPI< } } else { // No model specified - use selection system - // Build metadata with required capabilities and parameters + // Build metadata with required + optional capabilities and parameters. + // Optional entries are *preferences* (registry scores them with up to + // 2x for capabilities, 1.5x for parameters) — they bias selection + // toward capable models without filtering anyone out. Required entries + // are hard filters. ChatAPI promotes per-tool/per-prompt `strict` + // requests into the right tier here (true → required, number → + // optional preference); see `getRequiredCapabilities` and + // `getOptionalCapabilities` in chat.ts. const metadataRequired: AIMetadataRequired = { ...ctx.metadata, required: this.getRequiredCapabilities(ctx.metadata?.required || [], request, forStreaming), + optional: this.getOptionalCapabilities(ctx.metadata?.optional || [], request, forStreaming), requiredParameters: this.getRequiredParameters(ctx.metadata?.requiredParameters || [], request, forStreaming), + optionalParameters: this.getOptionalParameters(ctx.metadata?.optionalParameters || [], request, forStreaming), } as AIMetadataRequired; // Build metadata from what used passed in context @@ -377,6 +386,26 @@ export abstract class BaseAPI< // OPTIONAL OVERRIDES (default implementations provided) // ============================================================================ + /** + * Get optional (preferred) capabilities for model selection. + * + * Default implementation returns just what the caller provided. Subclasses + * override to auto-derive preferences from request shape — e.g. ChatAPI + * adds `'toolsStrict'` when any tool has `strict: true`. Optional entries + * never filter models out; they bias selection scoring toward capable models. + */ + protected getOptionalCapabilities(provided: ModelCapability[], request: TRequest, forStreaming: boolean): ModelCapability[] { + return [...provided]; + } + + /** + * Get optional (preferred) parameters for model selection. Same role as + * `getOptionalCapabilities` but for parameter-level matching. + */ + protected getOptionalParameters(provided: ModelParameter[], request: TRequest, forStreaming: boolean): ModelParameter[] { + return [...provided]; + } + /** * Get required capabilities for streaming (default: adds 'streaming') * @param provided - Additional capabilities provided by caller diff --git a/packages/ai/src/apis/chat.ts b/packages/ai/src/apis/chat.ts index 6af10460..39b3e3d7 100644 --- a/packages/ai/src/apis/chat.ts +++ b/packages/ai/src/apis/chat.ts @@ -52,6 +52,18 @@ import type { } from '../types'; import { BaseAPI } from './base'; +/** + * Returns true when a `strict` field expresses a numeric (best-effort) + * preference rather than a hard `true` / `false`. Default-omitted (undefined) + * is treated as `1` per the v2 default policy in tool/prompt JSDoc, so it + * also counts as numeric preference here. + */ +function isNumericStrictPreference(strict: boolean | number | undefined): boolean { + if (strict === undefined) return true; // default 1 + if (typeof strict === 'number') return strict > 0; + return false; // true and false are non-preferences for this purpose +} + /** * ChatAPI provides methods for chat completions with automatic model selection. * Inherits get() and stream() methods from BaseAPI. @@ -116,6 +128,16 @@ export class ChatAPI extends BaseAPI< // Check if request uses tools if (request.tools && request.tools.length > 0) { capabilities.add('tools'); + + // Tri-state strict: any tool with `strict === true` is a HARD + // requirement that the chosen model support strict tools. Numeric + // priority is handled in `getOptionalCapabilities` (preference, not + // filter). Default-omitted strict (treated as `1` downstream) is + // *not* a hard requirement — selection still succeeds against + // models without strict-family declarations. + if (request.tools.some(t => t.strict === true)) { + capabilities.add('toolsStrict'); + } } } @@ -168,6 +190,42 @@ export class ChatAPI extends BaseAPI< return Array.from(params); } + /** + * Optional (preferred) capabilities. Adds `'toolsStrict'` when any tool + * is requesting strict at numeric priority (or default-omitted, which is + * treated as priority `1`). Hard `strict: true` is handled in the + * required path; `strict: false` adds nothing. + * + * Optional preferences score strict-tool-capable models higher without + * filtering anyone out, matching the best-effort philosophy. + */ + protected getOptionalCapabilities(provided: ModelCapability[], request: Request, forStreaming: boolean): ModelCapability[] { + const optional = new Set(provided); + + if ((request.tools?.length ?? 0) > 0) { + const wantsStrictPreference = request.tools!.some(t => isNumericStrictPreference(t.strict)); + if (wantsStrictPreference) { + optional.add('toolsStrict'); + } + } + + return Array.from(optional); + } + + /** + * Optional (preferred) parameters. Same logic as + * `getOptionalCapabilities` but at the parameter level. + */ + protected getOptionalParameters(provided: ModelParameter[], request: Request, forStreaming: boolean): ModelParameter[] { + const params = new Set(provided); + + if (request.tools?.some(t => isNumericStrictPreference(t.strict))) { + params.add('toolsStrict'); + } + + return Array.from(params); + } + protected getNoModelFoundError(): string { return 'No compatible model found for criteria'; } diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index c94a8e00..c83b7d8b 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -5,6 +5,7 @@ */ export * from './types'; +export * from './common'; export * from './modelDetection'; export * from './ai'; export * from './registry'; diff --git a/packages/ai/src/registry.ts b/packages/ai/src/registry.ts index 71736ac9..3332be0f 100644 --- a/packages/ai/src/registry.ts +++ b/packages/ai/src/registry.ts @@ -5,7 +5,7 @@ * Provider order ensures deterministic model resolution: first provider to return a model ID wins. */ -import { getModel } from '@aeye/core'; +import { type DescriptorFamily, type FormatDescriptor, getDescriptor, getModel, hasDescriptorFamily, LENIENT } from '@aeye/core'; import { detectTier } from './modelDetection'; import type { AIBaseMetadata, @@ -21,6 +21,44 @@ import type { SelectedModel } from './types'; +// ============================================================================ +// Strict-format resolution +// ============================================================================ + +/** + * Resolve a model's strict-mode JSON-Schema dialect via three-step fallback: + * + * 1. Explicit `model.strictFormat` if it's a registered family + * (`'openai'` / `'anthropic'` / `'google'` ship by default; custom + * families registered via `registerDescriptor` in `@aeye/core` work + * here too). A literal `'none'` overrides the fallback chain and + * returns `undefined`. + * 2. `model.provider` if it matches a registered family (covers direct + * provider setups: `provider: 'openai'` → `'openai'`). + * 3. The `[family]/...` prefix of `model.id` (covers OpenRouter and + * similar routing layers: `id: 'openai/gpt-4o'` → `'openai'`). + * + * Returns `undefined` when none of the above apply — provider falls back + * to LENIENT silently. Used by registry `applyOverrides` to auto-derive + * the `'toolsStrict'` capability and by providers to pick a descriptor at + * request build time. + */ +export function resolveStrictFormat(model: { + id: string; + provider: string; + strictFormat?: DescriptorFamily | 'none'; +}): DescriptorFamily | undefined { + if (model.strictFormat === 'none') return undefined; + if (model.strictFormat) return model.strictFormat; + if (hasDescriptorFamily(model.provider)) return model.provider; + const slashIdx = model.id.indexOf('/'); + if (slashIdx > 0) { + const prefix = model.id.slice(0, slashIdx); + if (hasDescriptorFamily(prefix)) return prefix; + } + return undefined; +} + // ============================================================================ // Generic Constraint Helpers // ============================================================================ @@ -693,6 +731,29 @@ export class ModelRegistry< return this.providerCapabilities.get(name); } + /** + * Resolve the `FormatDescriptor` to use when sending a strict-flagged + * request to a given model. + * + * Returns `LENIENT` when the request is non-strict or when + * `resolveStrictFormat` can't determine a family for the model — strict + * is best-effort, the provider always gets *some* valid descriptor. + * + * Tools and structured output share the same dialect (per-model), so this + * helper takes no `kind` argument. Whether tools-strict or output-strict + * is actually engaged is gated by the `'toolsStrict'` and `'structured'` + * capabilities respectively, not by this helper. + */ + getStrictFormat( + model: ModelInfo, + requested: boolean, + ): FormatDescriptor { + if (!requested) return LENIENT; + const family = resolveStrictFormat(model); + if (!family) return LENIENT; + return getDescriptor(family, true); + } + /** * Check if a model is applicable given the criteria. * Returns null if not applicable, or an object with matched capabilities if applicable. @@ -1016,6 +1077,24 @@ export class ModelRegistry< }; } + // Capability is opt-IN, driven by the explicit `strictFormat` field — + // the curated table (or a scraper) lists models that ACTUALLY support + // strict. The fallback chain in `resolveStrictFormat` is only consulted + // at request-build time to pick the dialect, not to gate support. + // + // - `strictFormat: 'openai' | 'anthropic' | 'google'` adds 'toolsStrict'. + // - `strictFormat: 'none'` removes 'toolsStrict' if some upstream source + // (e.g. a scraper) had set it incorrectly. + // - `strictFormat: undefined` leaves capabilities untouched. + if (result.strictFormat === 'none') { + if (result.capabilities.has('toolsStrict')) { + result.capabilities = new Set(result.capabilities); + result.capabilities.delete('toolsStrict'); + } + } else if (result.strictFormat) { + result.capabilities = new Set([...result.capabilities, 'toolsStrict']); + } + return result; } @@ -1050,6 +1129,9 @@ export class ModelRegistry< maxOutputTokens: source.maxOutputTokens ?? base.maxOutputTokens, metrics: mergedMetrics, metadata: mergedMetadata, + supportedParameters: source.supportedParameters ?? base.supportedParameters, + tokenizer: source.tokenizer ?? base.tokenizer, + strictFormat: source.strictFormat ?? base.strictFormat, }; } @@ -1096,8 +1178,12 @@ export class ModelRegistry< // Create key for looking up source info const key = `${providerName as string}/${modelInfo.id}`; - // Create base model with defaults + // Create base model with defaults. Spread first so all fields the + // provider declared (including future-added ones like + // `strictFormat`, `supportedParameters`, `tokenizer`) reach the + // registry; then override with the canonical defaults below. let fullModel: ModelInfo = { + ...modelInfo, id: modelInfo.id, provider: providerName, name: modelInfo.name || modelInfo.id, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 2d5a8e70..de99553e 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -17,6 +17,7 @@ import type { ComponentInput, ComponentOutput, Context as CoreContext, + DescriptorFamily, Executor, Message, Model, @@ -108,7 +109,14 @@ export type ModelCapability = | 'audio' | 'hearing' | 'embedding' - | 'zdr'; + | 'zdr' + // Strict tool-input enforcement. Auto-derived from `resolveStrictFormat(model)` returning a family. + // Selection treats this as an *optional preference* when a request has any + // strict-flagged tool — strict-capable models score higher but non-strict + // models still match (silent best-effort fallback). + // (`'structured'` already implies strict structured output and `'json'` + // covers the lenient case, so no separate `structuredStrict` capability.) + | 'toolsStrict'; /** * Model performance and quality tiers. @@ -219,6 +227,27 @@ export interface ModelInfo extends Model { tokenizer?: ModelTokenizer; // The supported parameters supportedParameters?: Set; + /** + * Strict-mode JSON-Schema dialect this model expects. + * + * Used by both tools (gated by the `'toolsStrict'` capability) and + * structured output (gated by `'structured'`). One field covers both + * because every documented strict-capable model uses the same dialect + * for both. + * + * - `'openai'`: records→array-of-pairs, tuples→object-numeric-keys, optional→nullable + * - `'anthropic'`: closed objects + all-required, no recursion, per-request budgets + * - `'google'`: prefixItems, `$ref: '#'` recursion, `propertyOrdering` emitted + * - `'none'`: explicit opt-out — overrides the auto-resolution from + * `provider` / `id` and forces lenient mode + * - any other registered family (via `registerDescriptor` in `@aeye/core`) + * — custom dialect picked up at request-build time + * - `undefined` (most models): auto-resolve via `resolveStrictFormat(model)`, + * which checks `provider` against registered family names, then the + * `[format]/...` prefix of `id`. The `'toolsStrict'` capability is + * auto-derived from a successful resolution. + */ + strictFormat?: DescriptorFamily | 'none'; // Additional provider-specific metadata metadata?: Record; } @@ -260,6 +289,7 @@ export type ModelParameter = | 'transcribePrompt' // prompt // Speech | 'speechInstructions' // instructions + | 'toolsStrict' // tools[].strict /** * Override configuration for model properties. diff --git a/packages/aws/src/aws.ts b/packages/aws/src/aws.ts index eb9099a1..11af353c 100644 --- a/packages/aws/src/aws.ts +++ b/packages/aws/src/aws.ts @@ -16,14 +16,23 @@ import type { ModelInfo, Provider } from '@aeye/ai'; +import { isModelInfo, resolveStrictFormat } from '@aeye/ai'; import { Chunk, + type DescriptorFamily, Executor, FinishReason, + type FormatDescriptor, + getDescriptor, getModel, + LENIENT, ModelInput, Request, Response, + SchemaBudget, + strictestOf, + strictify, + strictPriority, Streamer, toJSONSchema, ToolCall, @@ -54,6 +63,29 @@ import { AWSAuthError, AWSContextWindowError, AWSError, AWSQuotaError, AWSRateLi // AWS Bedrock Provider Configuration // ============================================================================ +/** + * Resolve the full ModelInfo for the request when available. + * + * BaseAPI injects `selected.model` into `ctx.metadata.model` after model + * selection; on the standard execution path we read strict-format + * declarations from there. Returns undefined when no ModelInfo is available + * (the convert call then falls back to LENIENT — silent best-effort). + */ +function pickAwsModelInfo(ctx: AIContextAny | undefined, modelId: string): ModelInfo | undefined { + const candidates = [ctx?.metadata?.model]; + for (const c of candidates) { + if (c && typeof c === 'object' && isModelInfo(c)) { + // The Bedrock provider applies a model prefix at request build time + // (e.g. arn:aws:...) so the modelId we see here may not match the + // ModelInfo.id verbatim. Accept by suffix match for safety. + if (c.id === modelId || modelId.endsWith(c.id)) { + return c as ModelInfo; + } + } + } + return undefined; +} + /** * Hook called before a request is made to the provider. * @@ -338,10 +370,16 @@ export class AWSBedrockProvider implements Provider { /** * Build a ConverseCommandInput from a generic @aeye/core Request. */ - private convertRequestToConverse(modelId: string, request: Request): ConverseCommandInput { + private convertRequestToConverse(modelId: string, request: Request, ctx?: AIContextAny): ConverseCommandInput { const messages = this.convertMessagesToConverse(request); const system = this.convertSystemToConverse(request); - const toolConfig = this.convertToolsToConverse(request); + const modelInfo = pickAwsModelInfo(ctx, modelId); + // Bedrock's Converse API has no separate structured-output endpoint, so + // the budget only needs to cover tool schemas. We still build it through + // the same helper so the strictness selection stays consistent with the + // OpenAI provider and any future structured-output extension. + const budget = this.buildSchemaBudget(modelInfo); + const toolConfig = this.convertToolsToConverse(request, modelInfo, budget); const model = getModel(request.model); const maxTokens = request.maxTokens ?? (typeof model !== 'string' && model?.maxOutputTokens ? model.maxOutputTokens : undefined); @@ -368,7 +406,7 @@ export class AWSBedrockProvider implements Provider { private async executeWithConverse(modelInput: ModelInput, request: Request, ctx: AIContextAny): Promise { const model = getModel(modelInput); const modelId = this.applyModelPrefix(model.id); - const params = this.convertRequestToConverse(modelId, request); + const params = this.convertRequestToConverse(modelId, request, ctx); try { const command = new ConverseCommand(params); @@ -430,7 +468,7 @@ export class AWSBedrockProvider implements Provider { private async* streamWithConverse(modelInput: ModelInput, request: Request, ctx: AIContextAny): AsyncGenerator { const model = getModel(modelInput); const modelId = this.applyModelPrefix(model.id); - const params = this.convertRequestToConverse(modelId, request) as ConverseStreamCommandInput; + const params = this.convertRequestToConverse(modelId, request, ctx) as ConverseStreamCommandInput; try { const command = new ConverseStreamCommand(params); @@ -700,19 +738,55 @@ export class AWSBedrockProvider implements Provider { /** * Convert @aeye/core tool definitions to AWS Bedrock Converse API tool format. + * + * Strict-tool support is best-effort and per-model: the descriptor is + * picked via `resolveStrictFormat(model)`, which falls back through + * `model.strictFormat` → `model.provider` → `id` prefix. Bedrock + * surfaces both Anthropic-family Claude models and (in some regions) + * OpenAI-shaped tools. Models without a declared family fall back to + * LENIENT — silent degradation. The chosen descriptor is pinned on each + * `ToolDefinition.descriptor` so the Prompt loop can apply the matching + * strictify when validating tool arguments. + * + * Anthropic enforces per-request limits (20 strict tools, 24 optional + * params, 16 union types across the whole request) — those are tracked + * by the shared `SchemaBudget` so over-budget tools degrade to LENIENT + * silently rather than failing the API call. */ - private convertToolsToConverse(request: Request): ConverseCommandInput['toolConfig'] { + private convertToolsToConverse( + request: Request, + model?: ModelInfo, + budget?: SchemaBudget, + ): ConverseCommandInput['toolConfig'] { if (!request.tools || request.tools.length === 0) return undefined; - const tools = request.tools.map(tool => ({ - toolSpec: { - name: tool.name, - description: tool.description, - inputSchema: { - json: toJSONSchema(tool.parameters, tool.strict ?? true) as unknown as JsonDocument, + const localBudget = budget ?? this.buildSchemaBudget(model); + + // Allocate descriptors in descending-priority order so the most-wanted + // strict tools consume budget first, then emit in original order. + const order = request.tools + .map((t, i) => ({ i, p: strictPriority(t.strict) })) + .sort((a, b) => b.p - a.p); + const descriptors: FormatDescriptor[] = new Array(request.tools.length); + for (const { i } of order) { + const tool = request.tools[i]; + descriptors[i] = localBudget.allocateTool(tool.parameters, tool.strict); + tool.descriptor = descriptors[i].id; + } + + const tools = request.tools.map((tool, i) => { + const descriptor = descriptors[i]; + const strictifiedSchema = strictify(tool.parameters, descriptor); + return { + toolSpec: { + name: tool.name, + description: tool.description, + inputSchema: { + json: toJSONSchema(strictifiedSchema, descriptor) as unknown as JsonDocument, + }, }, - }, - })) as BedrockTool[]; + }; + }) as BedrockTool[]; let toolChoice: BedrockToolChoice | undefined; if (request.toolChoice === 'auto') { @@ -726,6 +800,32 @@ export class AWSBedrockProvider implements Provider { return { tools, toolChoice }; } + /** + * The set of strict descriptor families this provider can speak through + * Bedrock. Defaults to `['openai', 'anthropic', 'google']` — Bedrock + * surfaces all three. Subclasses can extend to accept custom registered + * families. + */ + protected supportedStrictFamilies: ReadonlySet = new Set(['openai', 'anthropic', 'google']); + + /** + * Build a SchemaBudget tuned to the resolved strict-format family. + * Bedrock surfaces multiple model families; the descriptor pick handles + * any in `supportedStrictFamilies`. Mismatched families fall back to + * LENIENT silently. + */ + private buildSchemaBudget(model?: ModelInfo): SchemaBudget { + const family = model ? resolveStrictFormat(model) : undefined; + const familySupported = family !== undefined && this.supportedStrictFamilies.has(family); + const tools = familySupported && model!.capabilities.has('toolsStrict') + ? getDescriptor(family, true) + : LENIENT; + const out = familySupported && model!.capabilities.has('structured') + ? getDescriptor(family, true) + : LENIENT; + return new SchemaBudget(strictestOf(tools, out)); + } + /** * Map a Bedrock Converse API stop reason to the @aeye/core FinishReason type. */ diff --git a/packages/cletus/src/ai.ts b/packages/cletus/src/ai.ts index f3bdfa16..56ac4525 100644 --- a/packages/cletus/src/ai.ts +++ b/packages/cletus/src/ai.ts @@ -1,7 +1,7 @@ import { AI, ContextInfer, ToolInfer } from '@aeye/ai'; import { AWSBedrockProvider } from '@aeye/aws'; import { Usage, accumulateUsage, toJSONSchema } from '@aeye/core'; -import { models, replicateTransformers } from '@aeye/models'; +import { models, replicateTransformers, strictSupport } from '@aeye/models'; import { OpenAIProvider } from '@aeye/openai'; import { OpenRouterProvider } from '@aeye/openrouter'; import { ReplicateProvider } from '@aeye/replicate'; @@ -273,6 +273,11 @@ export function createCletusAI(configFile: ConfigFile, client: CletusClient) { return { ...ctx, userPrompt, cache: {} }; }, models: allModels, + // Curated strict-mode dialect declarations: opts strict-capable model + // families (gpt-4o+, claude 4.5+, gemini 2.0+) into the `'toolsStrict'` + // capability and pins their JSON-Schema dialect so providers emit the + // right wire shape. Without this, every model defaults to lenient. + modelOverrides: [...strictSupport], }).withHooks({ beforeRequest: async (ctx, request, selected, usage, cost) => { logger.log(`Cletus beforeRequest model=${selected.model.id}, usage=~${JSON.stringify(usage)}, cost=~${cost}:\n${JSON.stringify(request, jsonReplacer, 2)}`); diff --git a/packages/core/README.md b/packages/core/README.md index 584b046c..bdf68fe7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -424,9 +424,106 @@ const adaptivePrompt = new Prompt({ | `tools` | Function/tool calling | | `json` | JSON output mode | | `structured` | Structured outputs with schemas | +| `toolsStrict` | Strict-mode tool input schemas (auto-derived; see [Strict Mode](#strict-mode)) | | `reasoning` | Extended chain-of-thought reasoning | | `zdr` | Zero data retention | +## Strict Mode + +Each LLM provider that supports strict mode does so with its own JSON Schema dialect. `@aeye/core` reconciles them under a single `FormatDescriptor` so a Zod schema authored once works against any of them. + +### `strict` on Tool / Prompt + +`Tool.strict` and `Prompt.strict` are `boolean | number`: + +| Value | Meaning | +|---|---| +| `true` | **Require** strict. `@aeye/ai` filters selection down to models that declare strict support; the request fails if none qualify. | +| `false` | **Force** lenient. Standard JSON Schema; no `strict: true` on the wire. | +| `number > 0` (default `1`) | **Prefer** strict, accept fallback. Higher numbers win when there are more strict-requesting items than the chosen model's per-request budget allows. | +| omitted | Treated as `1`. | + +```typescript +new Tool({ name: 'must', /* default 1 */ schema: z.object({...}) }); // best-effort +new Tool({ name: 'critical', strict: true, schema: z.object({...}) }); // hard require +new Tool({ name: 'meh', strict: false, schema: z.object({...}) }); // never strict +new Tool({ name: 'top', strict: 100, schema: z.object({...}) }); // high priority +``` + +Default `1` keeps "it just works" against unannotated models — strict where the chosen model supports it, lenient elsewhere. Set `true` only when strict is non-negotiable. + +### Native format descriptors + +Seven descriptors ship out of the box: + +| Descriptor | Family / Strict | Notes | +|---|---|---| +| `OPENAI_STRICT` | openai / strict | Records → array-of-pairs, tuples → numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. | +| `ANTHROPIC_STRICT` | anthropic / strict | Closed objects, no recursion, no length / numeric range constraints, per-request slot budgets (20 strict tools / 24 optional params / 16 union types). | +| `GOOGLE_STRICT` | google / strict | `prefixItems`, `$ref: '#'` recursion, `propertyOrdering` emitted, restricted format whitelist. | +| `LENIENT` | lenient / non-strict | No rewrites, all features pass through. Default for unannotated models. | +| `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT`, `GOOGLE_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. | + +```typescript +import { OPENAI_STRICT, ANTHROPIC_STRICT, getDescriptor, toJSONSchema } from '@aeye/core'; + +const schema = z.object({ + name: z.string(), + tags: z.record(z.string(), z.string()), +}); + +// Same Zod, different wire shapes: +toJSONSchema(schema, OPENAI_STRICT); // tags → array of {key, value} +toJSONSchema(schema, ANTHROPIC_STRICT); // tags → array of {key, value} (open records unsupported) +toJSONSchema(schema, getDescriptor('google', true)); // tags → standard additionalProperties + +// Backward-compat overloads still work: +toJSONSchema(schema, true); // === OPENAI_STRICT +toJSONSchema(schema, false); // === LENIENT +``` + +### Registering a custom descriptor + +You can ship your own descriptor for a provider or dialect that isn't supported natively. Define it as a `FormatDescriptor` value and register it with `registerDescriptor(descriptor)`: + +```typescript +import { registerDescriptor, type FormatDescriptor, OPENAI_STRICT } from '@aeye/core'; + +const MY_PROVIDER_STRICT: FormatDescriptor = { + ...OPENAI_STRICT, // start from a known good baseline + id: 'my-provider-strict', + family: 'openai', // or 'anthropic' | 'google' | 'lenient' + // Override only the fields that differ from the baseline: + recordEncoding: 'open-record', // your provider supports additionalProperties: + tupleEncoding: 'prefix-items', + supportsRecursion: true, +}; + +registerDescriptor(MY_PROVIDER_STRICT); +``` + +Once registered: + +- Provider code can pass the descriptor to `toJSONSchema(schema, MY_PROVIDER_STRICT)` and `strictify(schema, MY_PROVIDER_STRICT)`. +- The Prompt's validation roundtrip (which looks up descriptors by id from the request) will resolve `'my-provider-strict'` correctly. +- A custom `Provider` implementation can return this descriptor from its `getStrictFormat`-equivalent helper. + +The descriptor's full field list is exported as the `FormatDescriptor` interface — every flag is documented inline. Common adjustments: + +| Field | Controls | +|---|---| +| `objectAllFieldsRequired` | Whether every property must appear in `required[]`. | +| `objectClosedByDefault` | Whether every object emits `additionalProperties: false`. | +| `recordEncoding` | `'array-of-pairs'` vs `'open-record'`. | +| `tupleEncoding` | `'object-numeric-keys'` vs `'prefix-items'` vs `'items-union'`. | +| `allowAllOf` / `allowAnyOf` / `allowOneOf` | JSON-Schema combinator support. | +| `supportedStringFormats` | `'all'` or a `Set` whitelist (`'date-time'`, `'email'`, etc.). | +| `optionalAsNullable` | Whether `optional` fields are emitted as `T \| null` (OpenAI strict) vs omitted from `required[]`. | +| `supportsRecursion` | Whether `$ref` self-reference works under this dialect. | +| `maxStrictTools` / `maxStrictOptionalParams` / `maxStrictUnionTypes` | Per-request budget caps; `undefined` means no documented limit. | + +You don't need to set every field — spread an existing descriptor and override deltas. Tested via `analyzeSchema` + the schema test suite; see the strict-mode guide in `@aeye/docs` for end-to-end examples. + ## API Reference ### Prompt diff --git a/packages/core/src/__tests__/common.test.ts b/packages/core/src/__tests__/common.test.ts index d143b010..f9966d94 100644 --- a/packages/core/src/__tests__/common.test.ts +++ b/packages/core/src/__tests__/common.test.ts @@ -504,14 +504,17 @@ describe('Common Utilities', () => { }); it('should aggregate reasoning from chunks', () => { + // `Chunk.reasoning` is the `Reasoning` object type (`{ content?: string, details?: ... }`), + // not a string. Reasoning text concatenates through `accumulateReasoning` → + // `appendMaybe` on the `content` field. const chunks: Chunk[] = [ - { reasoning: 'First thought' }, - { reasoning: ', second thought' }, + { reasoning: { content: 'First thought' } }, + { reasoning: { content: ', second thought' } }, { content: 'Answer' }, ]; const response = getResponseFromChunks(chunks); - expect(response.reasoning).toBe('First thought, second thought'); + expect(response.reasoning?.content).toBe('First thought, second thought'); }); it('should handle finishReason', () => { diff --git a/packages/core/src/__tests__/prompt-applicability-compatibility.test.ts b/packages/core/src/__tests__/prompt-applicability-compatibility.test.ts index aaf87d73..c607997b 100644 --- a/packages/core/src/__tests__/prompt-applicability-compatibility.test.ts +++ b/packages/core/src/__tests__/prompt-applicability-compatibility.test.ts @@ -324,9 +324,19 @@ describe('Prompt Final Coverage', () => { excludeMessages: true }); - const executor = createMockExecutor({ + // Snapshot messages at executor invocation time. The Prompt loop + // mutates `request.messages` after the executor returns (the + // assistant response is pushed in for the next iteration), so + // jest.fn().mock.calls captures a reference that reflects the + // post-mutation state — we have to snapshot here. + let messagesAtCall: number | undefined; + const baseExecutor = createMockExecutor({ response: { content: 'response', finishReason: 'stop' } }); + const executor: typeof baseExecutor = jest.fn(async (request, ctx, metadata, signal) => { + messagesAtCall = request.messages.length; + return baseExecutor(request, ctx, metadata, signal); + }); const ctx: Context<{}, {}> = { execute: executor, @@ -338,10 +348,8 @@ describe('Prompt Final Coverage', () => { await prompt.get('result', {}, ctx); - // Check that executor was called with only the prompt message - const call = (executor as any).mock.calls[0][0]; - expect(call.messages).toBeDefined(); - expect(call.messages.length).toBe(1); // Only the prompt's message + // The executor should have seen only the prompt's system message. + expect(messagesAtCall).toBe(1); }); it('should include context messages by default', async () => { diff --git a/packages/core/src/__tests__/prompt-core-features.test.ts b/packages/core/src/__tests__/prompt-core-features.test.ts index 76427623..a126a714 100644 --- a/packages/core/src/__tests__/prompt-core-features.test.ts +++ b/packages/core/src/__tests__/prompt-core-features.test.ts @@ -561,9 +561,17 @@ describe('Prompt', () => { excludeMessages: true }); - const executor = createMockExecutor({ + // Snapshot at call time — the Prompt loop mutates request.messages + // post-call (pushes assistant response), so jest.fn().mock.calls + // captures a reference that reflects the mutation. + let snapshot: { content: unknown }[] = []; + const baseExecutor = createMockExecutor({ response: { content: 'Response', finishReason: 'stop' } }); + const executor: typeof baseExecutor = jest.fn(async (request, ctx, metadata, signal) => { + snapshot = request.messages.map((m: any) => ({ content: m.content })); + return baseExecutor(request, ctx, metadata, signal); + }); const ctx: Context<{}, {}> = { execute: executor, @@ -574,9 +582,8 @@ describe('Prompt', () => { await prompt.get('result', {}, ctx); - const request = (executor as any).mock.calls[0][0]; - expect(request.messages).toHaveLength(1); // Only the prompt message - expect(request.messages[0].content).toContain('Standalone prompt'); + expect(snapshot).toHaveLength(1); + expect(snapshot[0].content).toContain('Standalone prompt'); }); }); diff --git a/packages/core/src/__tests__/prompt-tool-result-pairing.test.ts b/packages/core/src/__tests__/prompt-tool-result-pairing.test.ts new file mode 100644 index 00000000..7ccd1775 --- /dev/null +++ b/packages/core/src/__tests__/prompt-tool-result-pairing.test.ts @@ -0,0 +1,414 @@ +/** + * Prompt Tool Result Pairing Tests + * + * Verifies the `toolsComplete` flag (default true) guarantees that every + * `tool_calls[i]` on an emitted assistant message ends up with a matching + * `role: 'tool'` reply in `request.messages` — even when the abort signal + * fires mid-batch or a `ToolInterrupt` is thrown by one of several + * parallel tools. Without that guarantee the next round-trip 400s + * (OpenAI / Anthropic both reject unpaired `tool_calls`). + * + * Also verifies the abort-aware dispatch added alongside the synthesis + * pass: once `signal.aborted` is observed at the top of the sequential + * or parallel dispatch loop, subsequent tools are NOT started. The + * synthesis pass fills their unpaired slots with `[aborted: …]` + * placeholders. + * + * Suspend semantics are intentionally preserved — `PromptSuspend` still + * leaves its tool_call without a paired result so the caller can supply + * one on resume. + */ + +import { z } from 'zod'; +import { Prompt, PromptEvent } from '../prompt'; +import { AnyTool, Tool, PromptSuspend, ToolInterrupt } from '../tool'; +import { Context, Message } from '../types'; +import { createMockExecutor } from './mocks/executor.mock'; + +describe('Prompt tool_call ↔ tool result pairing', () => { + describe('toolsComplete: true (default) — synthesis pass', () => { + it('pairs every parallel tool_call when the signal aborts mid-batch', async () => { + // Fast tool returns normally; slow tool aborts the parent + // controller and then takes much longer than the dispatch loop + // will wait. Once `controller.abort()` fires from inside the + // fast tool, the signal-check at the top of the parallel-mode + // for-await loop breaks — the slow tool's promise is orphaned, + // and the synthesis pass below pairs its tool_call with an + // `[aborted: …]` placeholder. + const controller = new AbortController(); + let slowFinished = false; + const fastTool = new Tool({ + name: 'fast', + description: 'Fast tool', + instructions: 'Returns quickly', + schema: z.object({ id: z.number() }), + call: () => { + controller.abort(); + return 'fast-done'; + }, + }); + const slowTool = new Tool({ + name: 'slow', + description: 'Slow tool', + instructions: 'Returns slowly', + schema: z.object({ id: z.number() }), + call: async () => { + await new Promise((r) => setTimeout(r, 500)); + slowFinished = true; + return 'slow-done'; + }, + }); + + const prompt = new Prompt({ + name: 'two-tool-parallel', + description: 'Two tools in parallel', + content: 'Run both', + tools: [fastTool, slowTool], + toolExecution: 'parallel', + }); + + const executor = createMockExecutor({ + responses: [ + { + content: '', + finishReason: 'tool_calls', + toolCalls: [ + { id: 'call_fast', name: 'fast', arguments: '{"id":1}' }, + { id: 'call_slow', name: 'slow', arguments: '{"id":2}' }, + ], + }, + ], + }); + + const ctx: Context<{}, {}> = { + execute: executor, + messages: [], + signal: controller.signal, + }; + + const events: PromptEvent[] = []; + for await (const event of prompt.run({}, ctx)) { + events.push(event); + } + + const messageEvents = events.filter((e) => e.type === 'message') as Array<{ + type: 'message'; + message: Message; + request: { messages: Message[] }; + }>; + const finalMessages = messageEvents[messageEvents.length - 1]!.request.messages; + const assistantWithCalls = finalMessages.find( + (m) => m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0, + ); + expect(assistantWithCalls).toBeDefined(); + expect(assistantWithCalls!.toolCalls).toHaveLength(2); + + const toolResults = finalMessages.filter((m) => m.role === 'tool'); + // Every tool_call should have a matching role:'tool' message — that's the whole point. + expect(toolResults).toHaveLength(2); + const byCallId = new Map(toolResults.map((m) => [m.toolCallId, m.content as string])); + expect(byCallId.get('call_fast')).toBe('fast-done'); + expect(byCallId.get('call_slow')).toMatch(/^\[aborted:/); + }); + + it('pairs every sequential tool_call when the signal aborts after the first tool', async () => { + // Sequential mode: tool A fires `controller.abort()` from inside + // its body. The signal-check at the top of the sequential + // for-loop trips on the next iteration so tools B and C never + // have their `.run()` invoked. Synthesis pairs both with placeholders. + const controller = new AbortController(); + const bRun = jest.fn(); + const cRun = jest.fn(); + const aborter = new Tool({ + name: 'aborter', + description: 'Aborts after running', + instructions: 'Calls abort and returns', + schema: z.object({}), + call: () => { + controller.abort(); + return 'A-result'; + }, + }); + const toolB = new Tool({ + name: 'B', + description: 'Tool B', + instructions: 'Tool B body', + schema: z.object({}), + call: () => { + bRun(); + return 'B-result'; + }, + }); + const toolC = new Tool({ + name: 'C', + description: 'Tool C', + instructions: 'Tool C body', + schema: z.object({}), + call: () => { + cRun(); + return 'C-result'; + }, + }); + + const prompt = new Prompt({ + name: 'three-tools-sequential', + description: 'Three tools sequential', + content: 'Run all three', + tools: [aborter, toolB, toolC], + toolExecution: 'sequential', + }); + + const executor = createMockExecutor({ + responses: [ + { + content: '', + finishReason: 'tool_calls', + toolCalls: [ + { id: 'call_A', name: 'aborter', arguments: '{}' }, + { id: 'call_B', name: 'B', arguments: '{}' }, + { id: 'call_C', name: 'C', arguments: '{}' }, + ], + }, + ], + }); + + const ctx: Context<{}, {}> = { + execute: executor, + messages: [], + signal: controller.signal, + }; + + const events: PromptEvent[] = []; + for await (const event of prompt.run({}, ctx)) { + events.push(event); + } + + // Tools B and C must never run — the abort-aware dispatch + // short-circuits the loop before their .run() is reached. + expect(bRun).not.toHaveBeenCalled(); + expect(cRun).not.toHaveBeenCalled(); + + const messageEvents = events.filter((e) => e.type === 'message') as Array<{ + type: 'message'; + message: Message; + request: { messages: Message[] }; + }>; + const finalMessages = messageEvents[messageEvents.length - 1]!.request.messages; + const toolResults = finalMessages.filter((m) => m.role === 'tool'); + expect(toolResults).toHaveLength(3); + const byCallId = new Map(toolResults.map((m) => [m.toolCallId, m.content as string])); + expect(byCallId.get('call_A')).toBe('A-result'); + expect(byCallId.get('call_B')).toMatch(/^\[aborted:/); + expect(byCallId.get('call_C')).toMatch(/^\[aborted:/); + }); + + it('synthesizes placeholder when ToolInterrupt cuts a parallel batch short', async () => { + // First tool throws ToolInterrupt; second tool aborts so the + // loop short-circuits and never accumulates its result. Both + // tool_calls must end up paired in request.messages. + const controller = new AbortController(); + const interrupter = new Tool({ + name: 'interrupter', + description: 'Throws ToolInterrupt', + instructions: 'Throws an interrupt', + schema: z.object({}), + call: () => { + // Abort the controller too so the second tool's eventual + // yield gets skipped by the signal-check inside the loop. + controller.abort(); + throw new ToolInterrupt('user cancelled'); + }, + }); + const other = new Tool({ + name: 'other', + description: 'Other tool', + instructions: 'Other tool body', + schema: z.object({}), + call: async () => { + await new Promise((r) => setTimeout(r, 200)); + return 'other-done'; + }, + }); + + const prompt = new Prompt({ + name: 'interrupt-pair', + description: 'Interrupt in parallel', + content: 'Run both', + tools: [interrupter, other], + toolExecution: 'parallel', + }); + + const executor = createMockExecutor({ + responses: [ + { + content: '', + finishReason: 'tool_calls', + toolCalls: [ + { id: 'call_int', name: 'interrupter', arguments: '{}' }, + { id: 'call_other', name: 'other', arguments: '{}' }, + ], + }, + ], + }); + + const ctx: Context<{}, {}> = { + execute: executor, + messages: [], + signal: controller.signal, + }; + + const events: PromptEvent[] = []; + for await (const event of prompt.run({}, ctx)) { + events.push(event); + } + + const messageEvents = events.filter((e) => e.type === 'message') as Array<{ + type: 'message'; + message: Message; + request: { messages: Message[] }; + }>; + const finalMessages = messageEvents[messageEvents.length - 1]!.request.messages; + const toolResults = finalMessages.filter((m) => m.role === 'tool'); + // Both tool_calls paired — no broken history. + expect(toolResults).toHaveLength(2); + // The interrupter's result content carries the interrupt marker; + // the other tool's slot is filled by synthesis. + const byCallId = new Map(toolResults.map((m) => [m.toolCallId, m.content as string])); + expect(byCallId.get('call_int')).toMatch(/cancelled|interrupted/i); + expect(byCallId.get('call_other')).toMatch(/^\[aborted:|^\[no result\]/); + }); + }); + + describe('toolsComplete: false — opt-out preserves legacy behavior', () => { + it('leaves unfinished tool_calls unpaired when opted out', async () => { + const controller = new AbortController(); + const fastTool = new Tool({ + name: 'fast', + description: 'Fast', + instructions: 'Returns quickly', + schema: z.object({}), + call: () => { + controller.abort(); + return 'fast-done'; + }, + }); + const slowTool = new Tool({ + name: 'slow', + description: 'Slow', + instructions: 'Returns slowly', + schema: z.object({}), + call: async () => { + await new Promise((r) => setTimeout(r, 500)); + return 'slow-done'; + }, + }); + + const prompt = new Prompt({ + name: 'opt-out', + description: 'opt-out of synthesis', + content: 'Run both', + tools: [fastTool, slowTool], + toolExecution: 'parallel', + toolsComplete: false, + }); + + const executor = createMockExecutor({ + responses: [ + { + content: '', + finishReason: 'tool_calls', + toolCalls: [ + { id: 'call_fast', name: 'fast', arguments: '{}' }, + { id: 'call_slow', name: 'slow', arguments: '{}' }, + ], + }, + ], + }); + + const ctx: Context<{}, {}> = { + execute: executor, + messages: [], + signal: controller.signal, + }; + + const events: PromptEvent[] = []; + for await (const event of prompt.run({}, ctx)) { + events.push(event); + } + + const messageEvents = events.filter((e) => e.type === 'message') as Array<{ + type: 'message'; + message: Message; + request: { messages: Message[] }; + }>; + const finalMessages = messageEvents[messageEvents.length - 1]!.request.messages; + const toolResults = finalMessages.filter((m) => m.role === 'tool'); + // Legacy behavior: only the fast tool's result lands. The slow + // tool's `tool_call` is left unpaired (the broken state the + // default `toolsComplete: true` is designed to prevent). + expect(toolResults).toHaveLength(1); + expect(toolResults[0]!.toolCallId).toBe('call_fast'); + }); + }); + + describe('suspend semantics — unchanged by toolsComplete', () => { + it('does NOT synthesize a placeholder for a PromptSuspend-throwing tool', async () => { + // Suspend/resume relies on the missing result slot. The + // synthesis pass must skip `status === 'suspended'`. + const suspendingTool = new Tool({ + name: 'suspender', + description: 'Suspends execution', + instructions: 'Throws PromptSuspend', + schema: z.object({}), + call: () => { + throw new PromptSuspend('Waiting for approval'); + }, + }); + + const prompt = new Prompt({ + name: 'suspend-no-synth', + description: 'Suspend test', + content: 'Run', + tools: [suspendingTool], + // explicit-true to prove the suspend short-circuit holds + toolsComplete: true, + }); + + const executor = createMockExecutor({ + responses: [ + { + content: '', + finishReason: 'tool_calls', + toolCalls: [{ id: 'call_susp', name: 'suspender', arguments: '{}' }], + }, + ], + }); + + const ctx: Context<{}, {}> = { + execute: executor, + messages: [], + }; + + const events: PromptEvent[] = []; + for await (const event of prompt.run({}, ctx)) { + events.push(event); + } + + const suspendEvent = events.find((e) => e.type === 'suspend') as + | { type: 'suspend'; request: { messages: Message[] } } + | undefined; + expect(suspendEvent).toBeDefined(); + + const finalMessages = suspendEvent!.request.messages; + const toolResults = finalMessages.filter((m) => m.role === 'tool'); + // No tool result — synthesis correctly skipped the suspended tool. + expect(toolResults).toHaveLength(0); + // Assistant message with the tool_call is present, as before. + const assistant = finalMessages.find( + (m) => m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0, + ); + expect(assistant).toBeDefined(); + expect(assistant!.toolCalls![0]!.id).toBe('call_susp'); + }); + }); +}); diff --git a/packages/core/src/__tests__/schema-budget.test.ts b/packages/core/src/__tests__/schema-budget.test.ts new file mode 100644 index 00000000..dbed6266 --- /dev/null +++ b/packages/core/src/__tests__/schema-budget.test.ts @@ -0,0 +1,278 @@ +/** + * SchemaBudget + analyzeSchema tests. + * + * Verifies per-request strict-slot allocation under each named descriptor, + * including silent fallback for infeasible schemas (recursion under + * Anthropic), priority-ordered budget consumption, and shared budgets + * across tools + structured output. + */ + +import z from 'zod'; +import { + ANTHROPIC_STRICT, + GOOGLE_STRICT, + LENIENT, + OPENAI_STRICT, + SchemaBudget, + analyzeSchema, + isStrictFeasible, + strictPriority, + strictestOf, +} from '../schema'; + +describe('analyzeSchema', () => { + it('counts optional parameters in nested objects', () => { + const schema = z.object({ + name: z.string(), + age: z.number().optional(), + address: z.object({ + line1: z.string(), + line2: z.string().optional(), + }), + }); + const features = analyzeSchema(schema); + expect(features.optionalParameterCount).toBe(2); + }); + + it('counts unions including nullables', () => { + const schema = z.object({ + payload: z.union([z.string(), z.number()]), + maybe: z.string().nullable(), + }); + const features = analyzeSchema(schema); + expect(features.unionTypeCount).toBe(2); + }); + + it('counts records and tuples', () => { + const schema = z.object({ + tags: z.record(z.string(), z.string()), + pair: z.tuple([z.string(), z.number()]), + }); + const features = analyzeSchema(schema); + expect(features.recordCount).toBe(1); + expect(features.tupleCount).toBe(1); + }); + + it('detects recursion via z.lazy', () => { + type Node = { value: string; children?: Node[] }; + const NodeSchema: z.ZodType = z.lazy(() => + z.object({ + value: z.string(), + children: z.array(NodeSchema).optional(), + }) + ); + const features = analyzeSchema(NodeSchema); + expect(features.hasRecursion).toBe(true); + }); + + it('returns same reference on repeat calls (cached)', () => { + const schema = z.object({ name: z.string() }); + const a = analyzeSchema(schema); + const b = analyzeSchema(schema); + expect(a).toBe(b); + }); +}); + +describe('isStrictFeasible', () => { + it('returns true for non-recursive schemas under any descriptor', () => { + const schema = z.object({ name: z.string() }); + expect(isStrictFeasible(schema, OPENAI_STRICT)).toBe(true); + expect(isStrictFeasible(schema, ANTHROPIC_STRICT)).toBe(true); + expect(isStrictFeasible(schema, GOOGLE_STRICT)).toBe(true); + }); + + it('returns false for recursive schemas under ANTHROPIC_STRICT', () => { + type Node = { value: string; children?: Node[] }; + const NodeSchema: z.ZodType = z.lazy(() => + z.object({ + value: z.string(), + children: z.array(NodeSchema).optional(), + }) + ); + expect(isStrictFeasible(NodeSchema, ANTHROPIC_STRICT)).toBe(false); + expect(isStrictFeasible(NodeSchema, OPENAI_STRICT)).toBe(true); + expect(isStrictFeasible(NodeSchema, GOOGLE_STRICT)).toBe(true); + }); + + it('always returns true for LENIENT', () => { + type Node = { value: string; children?: Node[] }; + const NodeSchema: z.ZodType = z.lazy(() => + z.object({ + value: z.string(), + children: z.array(NodeSchema).optional(), + }) + ); + expect(isStrictFeasible(NodeSchema, LENIENT)).toBe(true); + }); +}); + +describe('strictPriority', () => { + it('maps strict tri-state to numeric priority', () => { + expect(strictPriority(true)).toBe(Infinity); + expect(strictPriority(false)).toBe(-Infinity); + expect(strictPriority(5)).toBe(5); + expect(strictPriority(1)).toBe(1); + expect(strictPriority(0)).toBe(0); + expect(strictPriority(-2)).toBe(0); + expect(strictPriority(undefined)).toBe(0); + }); +}); + +describe('strictestOf', () => { + it('picks the strict descriptor over LENIENT', () => { + expect(strictestOf(LENIENT, OPENAI_STRICT)).toBe(OPENAI_STRICT); + expect(strictestOf(ANTHROPIC_STRICT, LENIENT)).toBe(ANTHROPIC_STRICT); + }); + + it('picks the smaller-budget descriptor when both are strict', () => { + // Anthropic has documented per-request limits; OpenAI does not. Anthropic wins. + expect(strictestOf(OPENAI_STRICT, ANTHROPIC_STRICT)).toBe(ANTHROPIC_STRICT); + expect(strictestOf(ANTHROPIC_STRICT, GOOGLE_STRICT)).toBe(ANTHROPIC_STRICT); + }); +}); + +describe('SchemaBudget — basic allocation', () => { + const simple = z.object({ a: z.string() }); + + it('returns LENIENT when requested === false', () => { + const budget = new SchemaBudget(OPENAI_STRICT); + expect(budget.allocateTool(simple, false)).toBe(LENIENT); + }); + + it('returns the descriptor when requested === true and feasible', () => { + const budget = new SchemaBudget(OPENAI_STRICT); + expect(budget.allocateTool(simple, true)).toBe(OPENAI_STRICT); + }); + + it('returns the descriptor for numeric priority when budget is uncapped', () => { + const budget = new SchemaBudget(OPENAI_STRICT); // OpenAI has no slot limit + expect(budget.allocateTool(simple, 5)).toBe(OPENAI_STRICT); + expect(budget.allocateTool(simple, 1)).toBe(OPENAI_STRICT); + }); + + it('returns LENIENT for undefined / 0 priority', () => { + const budget = new SchemaBudget(OPENAI_STRICT); + expect(budget.allocateTool(simple, undefined)).toBe(LENIENT); + expect(budget.allocateTool(simple, 0)).toBe(LENIENT); + }); + + it('returns LENIENT when descriptor is itself LENIENT', () => { + const budget = new SchemaBudget(LENIENT); + expect(budget.allocateTool(simple, true)).toBe(LENIENT); + expect(budget.allocateTool(simple, 5)).toBe(LENIENT); + }); +}); + +describe('SchemaBudget — Anthropic per-request limits', () => { + const simple = z.object({ a: z.string() }); + + it('allocates up to maxStrictTools (20) for numeric-priority items, then falls back', () => { + const budget = new SchemaBudget(ANTHROPIC_STRICT); + let granted = 0; + let lenient = 0; + for (let i = 0; i < 25; i++) { + const d = budget.allocateTool(simple, 5); + if (d.strict) granted++; + else lenient++; + } + expect(granted).toBe(20); + expect(lenient).toBe(5); + expect(budget.remaining().strictTools).toBe(0); + }); + + it('does NOT enforce tool budget for hard `true` items', () => { + // Hard requirements skip budget checks — model selection guaranteed + // feasibility, so over-budget is the user's responsibility. + const budget = new SchemaBudget(ANTHROPIC_STRICT); + for (let i = 0; i < 25; i++) { + const d = budget.allocateTool(simple, true); + expect(d).toBe(ANTHROPIC_STRICT); + } + }); + + it('exhausts optional-params budget across many items with optionals', () => { + const oneOptional = z.object({ + a: z.string(), + b: z.string().optional(), + }); + const budget = new SchemaBudget(ANTHROPIC_STRICT); // 24 optional params allowed + + let granted = 0; + for (let i = 0; i < 30; i++) { + const d = budget.allocateTool(oneOptional, 5); + if (d.strict) granted++; + } + // Each tool has 1 optional param; 24 fit, then optional-param budget runs out. + expect(granted).toBe(20); // bounded first by maxStrictTools=20 actually + }); + + it('exhausts union-types budget', () => { + const oneUnion = z.object({ + a: z.string(), + b: z.union([z.string(), z.number()]), + }); + const budget = new SchemaBudget(ANTHROPIC_STRICT); // 16 union types allowed + let granted = 0; + for (let i = 0; i < 20; i++) { + const d = budget.allocateTool(oneUnion, 5); + if (d.strict) granted++; + } + expect(granted).toBe(16); + }); + + it('falls back to LENIENT for recursive schemas under ANTHROPIC_STRICT', () => { + type Node = { value: string; children?: Node[] }; + const NodeSchema: z.ZodType = z.lazy(() => + z.object({ + value: z.string(), + children: z.array(NodeSchema).optional(), + }) + ); + const budget = new SchemaBudget(ANTHROPIC_STRICT); + expect(budget.allocateTool(NodeSchema, true)).toBe(LENIENT); + expect(budget.allocateTool(NodeSchema, 5)).toBe(LENIENT); + }); +}); + +describe('SchemaBudget — shared between tools and output', () => { + it('counts optional params across allocateTool + allocateOutput', () => { + // Build a custom-feeling case: schema has 13 optional params each. Two + // such tools + an output schema would exceed Anthropic's 24 cap. + const wide = z.object({ + a: z.string(), + b: z.string().optional(), + c: z.string().optional(), + d: z.string().optional(), + e: z.string().optional(), + f: z.string().optional(), + g: z.string().optional(), + h: z.string().optional(), + i: z.string().optional(), + j: z.string().optional(), + k: z.string().optional(), + l: z.string().optional(), + m: z.string().optional(), + n: z.string().optional(), + }); + const budget = new SchemaBudget(ANTHROPIC_STRICT); + expect(budget.allocateTool(wide, 5)).toBe(ANTHROPIC_STRICT); // 13 used, 11 left + // Second allocation needs 13 more; only 11 remain → fallback. + expect(budget.allocateTool(wide, 5)).toBe(LENIENT); + // Output schema also competes — but tool 1 already exhausted nearly the + // whole budget, so output fits or fails depending on its own optionals. + const small = z.object({ x: z.string() }); + expect(budget.allocateOutput(small, 5)).toBe(ANTHROPIC_STRICT); + }); +}); + +describe('SchemaBudget — descriptor.id pinning is the caller\'s job', () => { + // Allocate and ensure the descriptor returned is the configured one (not + // the LENIENT alias) so providers can pin descriptor.id for the validation + // roundtrip. + it('returns the descriptor instance, not a clone', () => { + const budget = new SchemaBudget(OPENAI_STRICT); + const d = budget.allocateTool(z.object({ a: z.string() }), true); + expect(d).toBe(OPENAI_STRICT); + expect(d.id).toBe('openai-strict'); + }); +}); diff --git a/packages/core/src/__tests__/schema-formats.test.ts b/packages/core/src/__tests__/schema-formats.test.ts new file mode 100644 index 00000000..a548399a --- /dev/null +++ b/packages/core/src/__tests__/schema-formats.test.ts @@ -0,0 +1,525 @@ +/** + * Format-descriptor matrix tests. + * + * Each provider/strict combination is represented by a `FormatDescriptor`. + * These tests verify that `toJSONSchema(schema, descriptor)` and + * `strictify(schema, descriptor)` produce the right shapes for + * representative Zod features under each named descriptor. + */ + +import z from 'zod'; +import { + ANTHROPIC_NON_STRICT, + ANTHROPIC_STRICT, + GOOGLE_NON_STRICT, + GOOGLE_STRICT, + LENIENT, + OPENAI_NON_STRICT, + OPENAI_STRICT, + type FormatDescriptor, + getDescriptor, + getDescriptorById, + hasDescriptorFamily, + registerDescriptor, + resolveDescriptor, + strictify, + toJSONSchema, +} from '../schema'; + +describe('FormatDescriptor matrix', () => { + describe('descriptor lookups', () => { + it('getDescriptor returns the right strict descriptor per family', () => { + expect(getDescriptor('openai', true)).toBe(OPENAI_STRICT); + expect(getDescriptor('anthropic', true)).toBe(ANTHROPIC_STRICT); + expect(getDescriptor('google', true)).toBe(GOOGLE_STRICT); + }); + + it('getDescriptor returns the family-tagged lenient when strict is false', () => { + // Each family has a registered non-strict slot aliased to LENIENT but + // tagged with the family name (used for diagnostics). They share + // LENIENT's behavior — verified by the dedicated alias test below. + expect(getDescriptor('openai', false)).toBe(OPENAI_NON_STRICT); + expect(getDescriptor('anthropic', false)).toBe(ANTHROPIC_NON_STRICT); + expect(getDescriptor('google', false)).toBe(GOOGLE_NON_STRICT); + }); + + it('getDescriptor returns LENIENT for unknown families regardless of strict', () => { + expect(getDescriptor('unregistered-family', true)).toBe(LENIENT); + expect(getDescriptor('unregistered-family', false)).toBe(LENIENT); + }); + + it('getDescriptorById round-trips named descriptors', () => { + expect(getDescriptorById('openai-strict')).toBe(OPENAI_STRICT); + expect(getDescriptorById('anthropic-strict')).toBe(ANTHROPIC_STRICT); + expect(getDescriptorById('google-strict')).toBe(GOOGLE_STRICT); + expect(getDescriptorById('lenient')).toBe(LENIENT); + }); + + it('getDescriptorById returns LENIENT for unknown ids', () => { + expect(getDescriptorById('unknown-id')).toBe(LENIENT); + expect(getDescriptorById(undefined)).toBe(LENIENT); + }); + + it('resolveDescriptor handles all input shapes', () => { + // boolean overload (legacy) + expect(resolveDescriptor(true)).toBe(OPENAI_STRICT); + expect(resolveDescriptor(false)).toBe(LENIENT); + // options overload — strict variants + expect(resolveDescriptor({ strict: true, format: 'anthropic' })).toBe(ANTHROPIC_STRICT); + expect(resolveDescriptor({ strict: true, format: 'google' })).toBe(GOOGLE_STRICT); + // options overload — non-strict variants resolve to the family's lenient slot + expect(resolveDescriptor({ strict: false, format: 'anthropic' })).toBe(ANTHROPIC_NON_STRICT); + // descriptor passthrough + expect(resolveDescriptor(ANTHROPIC_STRICT)).toBe(ANTHROPIC_STRICT); + }); + }); + + describe('object encoding', () => { + const schema = z.object({ + name: z.string(), + age: z.number().optional(), + }); + + it('OPENAI_STRICT marks every field required and closes the object', () => { + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.required).toEqual(['name', 'age']); + expect(json.additionalProperties).toBe(false); + // age is optional → nullable in OpenAI strict + const ageProp = json.properties!.age; + const ageType = Array.isArray(ageProp.type) ? ageProp.type : (ageProp.anyOf ? 'union' : ageProp.type); + expect(ageType).toBeDefined(); + }); + + it('ANTHROPIC_STRICT keeps optional fields out of required[] but closes the object', () => { + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.required).toEqual(['name']); // age is optional + expect(json.additionalProperties).toBe(false); + }); + + it('GOOGLE_STRICT keeps optional fields out of required[] and leaves the object open', () => { + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.required).toEqual(['name']); + expect(json.additionalProperties).toBeUndefined(); + }); + + it('LENIENT keeps optional fields out of required[] and leaves the object open', () => { + const json = toJSONSchema(schema, LENIENT); + expect(json.required).toEqual(['name']); + expect(json.additionalProperties).toBeUndefined(); + }); + }); + + describe('record encoding', () => { + const schema = z.object({ + tags: z.record(z.string(), z.string()), + }); + + it('OPENAI_STRICT encodes records as array-of-pairs', () => { + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.properties!.tags.type).toBe('array'); + expect(json.properties!.tags.items?.type).toBe('object'); + expect(json.properties!.tags.items?.properties).toHaveProperty('key'); + expect(json.properties!.tags.items?.properties).toHaveProperty('value'); + }); + + it('ANTHROPIC_STRICT encodes records as array-of-pairs (open records unsupported)', () => { + // Anthropic strict only allows additionalProperties: false (no schema), + // so open records are unrepresentable; we use the same array-of-pairs + // workaround as OpenAI. + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.properties!.tags.type).toBe('array'); + expect(json.properties!.tags.items?.properties).toHaveProperty('key'); + expect(json.properties!.tags.items?.properties).toHaveProperty('value'); + }); + + it('GOOGLE_STRICT keeps records as open-record', () => { + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.properties!.tags.type).toBe('object'); + expect(json.properties!.tags.additionalProperties).toBeDefined(); + }); + + it('LENIENT keeps records as open-record', () => { + const json = toJSONSchema(schema, LENIENT); + expect(json.properties!.tags.type).toBe('object'); + expect(json.properties!.tags.additionalProperties).toBeDefined(); + }); + }); + + describe('tuple encoding', () => { + const schema = z.object({ + pair: z.tuple([z.string(), z.number(), z.boolean()]), + }); + + it('OPENAI_STRICT encodes tuples as object-with-numeric-keys', () => { + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.properties!.pair.type).toBe('object'); + expect(Object.keys(json.properties!.pair.properties!).sort()).toEqual(['0', '1', '2']); + expect(json.properties!.pair.additionalProperties).toBe(false); + }); + + it('ANTHROPIC_STRICT collapses tuples to items-union (no positional support)', () => { + // Anthropic doesn't list prefixItems as a supported keyword; we collapse + // mixed-type tuples to a homogeneous `items: { anyOf: [...] }`. + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.properties!.pair.type).toBe('array'); + expect(json.properties!.pair.prefixItems).toBeUndefined(); + expect(json.properties!.pair.items?.anyOf).toHaveLength(3); + }); + + it('GOOGLE_STRICT uses prefixItems', () => { + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.properties!.pair.type).toBe('array'); + expect(json.properties!.pair.prefixItems).toHaveLength(3); + }); + + it('LENIENT uses prefixItems', () => { + const json = toJSONSchema(schema, LENIENT); + expect(json.properties!.pair.type).toBe('array'); + expect(json.properties!.pair.prefixItems).toHaveLength(3); + }); + }); + + describe('intersection (allOf vs anyOf)', () => { + const schema = z.intersection( + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ); + + it('OPENAI_STRICT collapses to anyOf (no allOf support)', () => { + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.anyOf).toBeDefined(); + expect(json.allOf).toBeUndefined(); + }); + + it('ANTHROPIC_STRICT keeps allOf', () => { + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.allOf).toBeDefined(); + }); + + it('GOOGLE_STRICT collapses allOf to anyOf (combinators not in supported list)', () => { + // Gemini's documented supported keywords don't include allOf/anyOf/oneOf; + // we conservatively avoid emitting them and degrade intersections to a + // best-effort anyOf representation. + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.allOf).toBeUndefined(); + expect(json.anyOf).toBeDefined(); + }); + + it('LENIENT keeps allOf', () => { + const json = toJSONSchema(schema, LENIENT); + expect(json.allOf).toBeDefined(); + }); + }); + + describe('z.any encoding', () => { + const schema = z.object({ data: z.any() }); + + it('OPENAI_STRICT uses recursive-strict (array-of-pairs object branch)', () => { + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.$defs!.Any).toBeDefined(); + const branches = json.$defs!.Any.anyOf!; + const objectBranch = branches.find(b => b.type === 'array' && b.items?.type === 'object'); + expect(objectBranch).toBeDefined(); + expect(objectBranch!.items!.properties).toHaveProperty('key'); + expect(objectBranch!.items!.properties).toHaveProperty('value'); + }); + + it('ANTHROPIC_STRICT uses recursive-strict (array-of-pairs object branch)', () => { + // Anthropic forbids `additionalProperties: `, so the Any + // schema's object branch uses the same array-of-pairs workaround as + // OpenAI strict. + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + const branches = json.$defs!.Any.anyOf!; + const objectBranch = branches.find(b => b.type === 'array' && b.items?.type === 'object'); + expect(objectBranch).toBeDefined(); + expect(objectBranch!.items!.properties).toHaveProperty('key'); + expect(objectBranch!.items!.properties).toHaveProperty('value'); + }); + + it('LENIENT uses recursive-open', () => { + const json = toJSONSchema(schema, LENIENT); + const branches = json.$defs!.Any.anyOf!; + const objectBranch = branches.find(b => b.type === 'object'); + expect(objectBranch?.additionalProperties).toBeDefined(); + }); + }); + + describe('string formats', () => { + it('OPENAI_STRICT keeps email (whitelisted) and drops non-whitelisted formats', () => { + const schema = z.object({ + contact: z.email(), + }); + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.properties!.contact.format).toBe('email'); + }); + + it('ANTHROPIC_STRICT passes whitelisted formats through', () => { + const schema = z.object({ + contact: z.email(), + unique: z.uuid(), + }); + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.properties!.contact.format).toBe('email'); + expect(json.properties!.unique.format).toBe('uuid'); + }); + + it('LENIENT passes all string formats through', () => { + const schema = z.object({ + contact: z.email(), + unique: z.uuid(), + }); + const json = toJSONSchema(schema, LENIENT); + expect(json.properties!.contact.format).toBe('email'); + expect(json.properties!.unique.format).toBe('uuid'); + }); + }); + + describe('strictify caching', () => { + it('returns the same reference on repeat calls with same descriptor', () => { + const schema = z.object({ name: z.string() }); + const a = strictify(schema, OPENAI_STRICT); + const b = strictify(schema, OPENAI_STRICT); + expect(a).toBe(b); + }); + + it('returns different references for different descriptors', () => { + const schema = z.object({ + items: z.record(z.string(), z.string()), + }); + const a = strictify(schema, OPENAI_STRICT); + const b = strictify(schema, ANTHROPIC_STRICT); + expect(a).not.toBe(b); + }); + + it('LENIENT returns the input schema unchanged', () => { + const schema = z.object({ name: z.string() }); + const result = strictify(schema, LENIENT); + expect(result).toBe(schema); + }); + + it('OPENAI_STRICT and ANTHROPIC_STRICT both accept the natural shape (lenient input)', async () => { + // Strictified schemas should still accept payloads in their natural Zod + // shape — important for test/dev workflows that pass plain JS objects. + const schema = z.object({ + items: z.record(z.string(), z.number()), + }); + const openaiStrict = strictify(schema, OPENAI_STRICT); + const anthropicStrict = strictify(schema, ANTHROPIC_STRICT); + + const naturalPayload = { items: { foo: 1, bar: 2 } }; + await expect(openaiStrict.parseAsync(naturalPayload)).resolves.toBeDefined(); + await expect(anthropicStrict.parseAsync(naturalPayload)).resolves.toBeDefined(); + }); + + it('OPENAI_STRICT also accepts the array-of-pairs wire shape', async () => { + const schema = z.object({ + items: z.record(z.string(), z.number()), + }); + const strict = strictify(schema, OPENAI_STRICT); + const wirePayload = { + items: [ + { key: 'foo', value: 1 }, + { key: 'bar', value: 2 }, + ], + }; + const parsed = await strict.parseAsync(wirePayload); + expect(parsed.items).toEqual({ foo: 1, bar: 2 }); + }); + }); + + describe('backward compatibility', () => { + it('toJSONSchema(schema, true) behaves identically to OPENAI_STRICT', () => { + const schema = z.object({ + name: z.string(), + tags: z.record(z.string(), z.string()), + }); + const a = toJSONSchema(schema, true); + const b = toJSONSchema(schema, OPENAI_STRICT); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('toJSONSchema(schema, false) behaves identically to LENIENT', () => { + const schema = z.object({ + name: z.string(), + tags: z.record(z.string(), z.string()), + }); + const a = toJSONSchema(schema, false); + const b = toJSONSchema(schema, LENIENT); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('strictify(schema) without descriptor defaults to OPENAI_STRICT', () => { + const schema = z.object({ name: z.string() }); + const a = strictify(schema); + const b = strictify(schema, OPENAI_STRICT); + expect(a).toBe(b); + }); + }); + + describe('Anthropic strict — unsupported constraint dropping', () => { + it('drops minimum/maximum on numbers', () => { + const schema = z.object({ age: z.number().min(0).max(120) }); + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.properties!.age.minimum).toBeUndefined(); + expect(json.properties!.age.maximum).toBeUndefined(); + }); + + it('drops minLength/maxLength on strings', () => { + const schema = z.object({ name: z.string().min(2).max(50) }); + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.properties!.name.minLength).toBeUndefined(); + expect(json.properties!.name.maxLength).toBeUndefined(); + }); + + it('drops minItems/maxItems on arrays', () => { + const schema = z.object({ tags: z.array(z.string()).min(1).max(10) }); + const json = toJSONSchema(schema, ANTHROPIC_STRICT); + expect(json.properties!.tags.minItems).toBeUndefined(); + expect(json.properties!.tags.maxItems).toBeUndefined(); + }); + }); + + describe('Google strict — propertyOrdering and constraint policy', () => { + it('emits propertyOrdering listing object properties in declaration order', () => { + const schema = z.object({ + zebra: z.string(), + apple: z.number(), + mango: z.boolean(), + }); + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.propertyOrdering).toEqual(['zebra', 'apple', 'mango']); + }); + + it('does NOT emit propertyOrdering under OPENAI_STRICT', () => { + const schema = z.object({ a: z.string(), b: z.number() }); + const json = toJSONSchema(schema, OPENAI_STRICT); + expect(json.propertyOrdering).toBeUndefined(); + }); + + it('keeps numeric minimum/maximum (documented support)', () => { + const schema = z.object({ age: z.number().min(0).max(120) }); + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.properties!.age.minimum).toBe(0); + expect(json.properties!.age.maximum).toBe(120); + }); + + it('drops minLength/maxLength (string-length not in supported list)', () => { + const schema = z.object({ name: z.string().min(2).max(50) }); + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.properties!.name.minLength).toBeUndefined(); + expect(json.properties!.name.maxLength).toBeUndefined(); + }); + + it('drops non-supported string formats (only date-time/date/time allowed)', () => { + const schema = z.object({ contact: z.email(), unique: z.uuid() }); + const json = toJSONSchema(schema, GOOGLE_STRICT); + expect(json.properties!.contact.format).toBeUndefined(); + expect(json.properties!.unique.format).toBeUndefined(); + }); + }); + + describe('registerDescriptor — custom family registration', () => { + it('registers a custom descriptor reachable by id and family', () => { + const CUSTOM_TIGHT: FormatDescriptor = { + ...OPENAI_STRICT, + id: 'test-tight', + family: 'test-tight', + allowPattern: false, + allowMultiplePatterns: false, + }; + registerDescriptor(CUSTOM_TIGHT); + + // Both lookup paths resolve. + expect(getDescriptorById('test-tight')).toBe(CUSTOM_TIGHT); + expect(getDescriptor('test-tight', true)).toBe(CUSTOM_TIGHT); + // Lenient slot for the same family is unset → falls back to LENIENT. + expect(getDescriptor('test-tight', false)).toBe(LENIENT); + }); + + it('makes the custom family discoverable via hasDescriptorFamily', () => { + registerDescriptor({ + ...OPENAI_STRICT, + id: 'test-discoverable', + family: 'test-discoverable', + }); + expect(hasDescriptorFamily('test-discoverable')).toBe(true); + expect(hasDescriptorFamily('test-not-registered')).toBe(false); + }); + + it('resolveDescriptor handles a registered family via {format, strict}', () => { + registerDescriptor({ + ...OPENAI_STRICT, + id: 'test-resolve', + family: 'test-resolve', + }); + const d = resolveDescriptor({ strict: true, format: 'test-resolve' }); + expect(d.id).toBe('test-resolve'); + }); + + it('strictify works against a custom descriptor', () => { + const CUSTOM: FormatDescriptor = { + ...OPENAI_STRICT, + id: 'test-strictify-custom', + family: 'test-strictify-custom', + }; + registerDescriptor(CUSTOM); + + const schema = z.object({ + items: z.record(z.string(), z.number()), + }); + const strictified = strictify(schema, CUSTOM); + // Inherits the OpenAI-strict behavior: array-of-pairs preprocess accepts the wire shape. + const parsed = strictified.parse({ items: [{ key: 'a', value: 1 }] }); + expect(parsed.items).toEqual({ a: 1 }); + }); + + it('toJSONSchema honors a registered descriptor', () => { + const CUSTOM: FormatDescriptor = { + ...ANTHROPIC_STRICT, + id: 'test-anthropic-variant', + family: 'test-anthropic-variant', + // Tweak: this variant DOES allow minimum/maximum on numbers. + allowMinimumMaximum: true, + }; + registerDescriptor(CUSTOM); + + const schema = z.object({ age: z.number().min(0).max(120) }); + const json = toJSONSchema(schema, CUSTOM); + // Inherited Anthropic shape: closed object, no record-as-pairs (records + // here unused), but customized to keep numeric range. + expect(json.properties!.age.minimum).toBe(0); + expect(json.properties!.age.maximum).toBe(120); + }); + + it('replacing a same-id descriptor updates the registry', () => { + registerDescriptor({ + ...OPENAI_STRICT, + id: 'test-replace', + family: 'test-replace', + allowPattern: true, + }); + registerDescriptor({ + ...OPENAI_STRICT, + id: 'test-replace', + family: 'test-replace', + allowPattern: false, // overwrite + }); + const d = getDescriptorById('test-replace'); + expect(d.allowPattern).toBe(false); + }); + }); + + describe('aliased non-strict descriptors', () => { + it('OPENAI_NON_STRICT, ANTHROPIC_NON_STRICT, GOOGLE_NON_STRICT all behave as LENIENT', () => { + const schema = z.object({ + name: z.string(), + tags: z.record(z.string(), z.string()), + }); + const lenient = JSON.stringify(toJSONSchema(schema, LENIENT)); + // The non-strict variants share LENIENT's shape but carry a different family tag. + expect(JSON.stringify(toJSONSchema(schema, OPENAI_NON_STRICT))).toBe(lenient); + expect(JSON.stringify(toJSONSchema(schema, ANTHROPIC_NON_STRICT))).toBe(lenient); + expect(JSON.stringify(toJSONSchema(schema, GOOGLE_NON_STRICT))).toBe(lenient); + }); + }); +}); diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index 8fa2b4d7..b171baa9 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -4,7 +4,13 @@ import { ZodString, ZodType } from 'zod'; import { accumulateReasoning, accumulateUsage, Fn, getChunksFromResponse, getInputTokens, getModel, getOutputTokens, getTotalTokens, resolve, Resolved, resolveFn, yieldAll } from "./common"; import { AnyTool, Tool, ToolCompatible, ToolInterrupt, PromptSuspend } from "./tool"; import { Component, Context, Events, Executor, FinishReason, Message, Names, OptionalParams, Reasoning, Request, RequiredKeys, ResponseFormat, Streamer, ToolCall, ToolDefinition, Tuple, Usage } from "./types"; -import { strictify, toJSONSchema } from "./schema"; +import { getDescriptorById, strictify } from "./schema"; + +/** Default cap (chars) for validation error messages surfaced back to the + * LLM. Read by `Prompt.truncateValidationError`; subclasses may override + * the method to ignore this. Anything past `max` is replaced with a + * `… (N more characters)` marker. */ +const DEFAULT_VALIDATION_ERROR_MAX_LENGTH = 4096; /** * Represents a tool that can be selected by the retool function. @@ -91,8 +97,23 @@ export interface PromptInput< input?: Fn, [TInput | undefined, Context]>; // A schema or function/promise that returns a schema defining the expected output format of the prompt. If not provided, defaults to plain text. schema?: Fn | false, [TInput | undefined, Context]>; - // If true, the output schema is strictly enforced. By default this is true. - strict?: boolean; + /** + * Strict-mode policy for the output schema. Tri-state, with `1` + * (best-effort preference) as the default when omitted: + * + * - `true` — REQUIRE strict structured output. Selection filters out + * models without the matching strict-output family. + * - `false` — FORCE lenient. Output emitted as standard JSON, no `strict` + * flag on the wire. + * - `number > 0` (default `1`) — PREFER strict, tolerate fallback. The + * number is the priority — used by `SchemaBudget` to allocate strict + * slots in priority order when the chosen descriptor has per-request + * limits (e.g. Anthropic's 24 optional-param ceiling). + * + * The legacy default of `true` was changed to `1` in v2 to keep + * "it just works" against unknown/unannotated models. + */ + strict?: boolean | number; // A configuration object or function/promise that returns a configuration object for the AI request. config?: Fn | false, [TInput | undefined, Context]>; // After an iteration, a function that can reconfigure the prompt based on runtime statistics. @@ -106,6 +127,13 @@ export interface PromptInput< toolExecution?: 'sequential' | 'parallel' | 'immediate'; // Number of attempts to retry tool calls upon failure. Defaults to 2. */ toolRetries?: number; + // Maximum number of characters of any validation error message + // (Zod tool-arg parse, output schema parse, output validate, JSON parse) + // surfaced back to the LLM as a corrective user message. Lengthy zod + // errors against deep recursive schemas can otherwise blow past 100k + // characters, blowing the model's context and the user's terminal both. + // Truncation appends a `… (N more characters)` marker. Default 4096. + validationErrorMaxLength?: number; // Number of attempts to get the output in the right format and to pass validation. Defaults to what's on the context, which defaults to 2. outputRetries?: number; // Number of attempts that will be made to forget context messages of the past in order to complete the request. Defaults to what's on the context, which defaults to 1. @@ -116,6 +144,32 @@ export interface PromptInput< toolIterations?: number; // Maximum tool calls allowed. We can't enforce this exact number unless toolsOneAtATime=true, but we will stop sending tools if we have tool successes >= this number toolsMax?: number; + /** + * Guarantee every `toolCalls[i]` on an emitted assistant message has a + * matching `role: 'tool'` reply in `request.messages` before the loop + * returns — even when execution was aborted via the signal or + * short-circuited by a `ToolInterrupt`. Without this guarantee, the + * next round-trip fails (OpenAI / Anthropic both reject unpaired + * `tool_calls`). + * + * The synthetic reply carries a short marker (`[interrupted]`, + * `[aborted: …]`, `[error: …]`) so the model can see WHY the tool + * didn't run and the caller can detect the synthesized state via the + * message content if they need to re-issue. + * + * `PromptSuspend` is NEVER auto-paired — suspend/resume relies on + * the missing result slot to know which tool to resume. Suspended + * tools still skip result emission regardless of this flag. + * + * Note: abort-aware dispatch (the `if (signal.aborted) break;` at + * the top of the sequential / parallel / immediate dispatch loops) + * runs unconditionally so Ctrl+C unwinds quickly regardless of this + * flag. Only the synthesis pass that fills in placeholder replies + * is gated. + * + * Default: `true`. + */ + toolsComplete?: boolean; // A function/promise that returns an array of tool names and/or tool objects to use, or false to indicate the prompt is not compatible with the context. // Tool names (strings) select from the predefined tools array, while tool objects allow for dynamic tools. retool?: Fn, [TInput | undefined, Context]>; @@ -295,7 +349,11 @@ export class Prompt< constructor( public input: PromptInput, private retool = resolveFn(input.retool), - private schema = resolveFn(input.schema, s => s && input.strict !== false ? strictify(s) as ZodType : s), + // Schema stays raw. The matching strictify is applied lazily at validation + // time using the descriptor pinned on `request.responseFormat.descriptor` + // by whichever provider built the request — so the validator always + // matches the wire shape the model actually saw. + private schema = resolveFn(input.schema), private config = resolveFn(input.config), private translate = resolveFn(input.input), private content = Prompt.compileContent(input.content, !!input.tools?.length), @@ -584,6 +642,7 @@ export class Prompt< let forgetRetries = this.input.forgetRetries ?? ctx.forgetRetries ?? 1; let toolIterations = this.input.toolIterations ?? 3; let toolRetries = this.input.toolRetries ?? ctx.toolRetries ?? 2; + const toolsComplete = this.input.toolsComplete ?? true; let result: TOutput | undefined = undefined; let lastError: string | undefined = undefined; @@ -617,6 +676,15 @@ export class Prompt< // Main execution loop! while (iterations < maxIterations) { + // Honor the caller's abort signal at iteration boundaries — if a + // tool dispatch already paired its tool_calls via the synthesis + // pass on the previous iteration, there's no reason to start + // another round-trip. Without this guard the loop would spin + // until `maxIterations` because each inner stream call would + // immediately observe `signal.aborted`, yield nothing, and fall + // through to "no tool calls" — wasting time and tokens for an + // already-cancelled request. + if (ctx.signal?.aborted) break; const toolExecutors: ToolExecution>[] = []; const toolExecutorMap = new Map>>(); const toolErrorsPrevious = (toolCallErrors + toolParseErrors); @@ -673,7 +741,13 @@ export class Prompt< // Handle tool calls if (chunk.toolCallNamed) { - const toolExecutor = newToolExecution(ctx, chunk.toolCallNamed, toolMap.get(chunk.toolCallNamed.name)); + const toolExecutor = newToolExecution( + ctx, + chunk.toolCallNamed, + toolMap.get(chunk.toolCallNamed.name), + this.input.validationErrorMaxLength, + this.truncateValidationError.bind(this), + ); toolExecutors.push(toolExecutor); toolExecutorMap.set(chunk.toolCallNamed.id, toolExecutor); @@ -814,6 +888,15 @@ export class Prompt< switch (iterationMode) { case 'sequential': for (const toolExecutor of toolExecutors) { + // Abort-aware dispatch — once the caller's signal trips, + // stop starting subsequent tools so Ctrl+C unwinds within + // ~1 tool's duration instead of grinding through the rest + // of the batch. The synthesis pass below pairs every + // unstarted tool_call with an `[aborted]` placeholder. + // We check `ctx.signal` directly (not the streamController + // relay) because the relay's listener was already + // removed by the time tool dispatch runs. + if (ctx.signal?.aborted) break; await toolExecutor.parse(); if (toolExecutor.emitStart()) { yield emitTool({ type: 'toolStart', tool: toolExecutor.tool!, args: toolExecutor.args, request }); @@ -837,6 +920,15 @@ export class Prompt< case 'immediate': const parseRuns = toolExecutors.map(tc => [tc.parse(), tc.run()]).flat(); for await (const { result: toolCallPromise } of yieldAll(parseRuns)) { + // The promises in `parseRuns` were started eagerly, so + // tools already in flight will keep running in the + // background (orphaned). What we control here is whether + // we accumulate their results / emit their events. Bail + // on abort so the caller doesn't have to wait for stragglers + // to finish — synthesis below covers any unpaired tool_call. + // (We check `ctx.signal` directly — streamController's + // listener was already removed before tool dispatch.) + if (ctx.signal?.aborted) break; const toolExecutor = await toolCallPromise; if (toolExecutor.emitStart()) { yield emitTool({ type: 'toolStart', tool: toolExecutor.tool!, args: toolExecutor.args, request }); @@ -857,22 +949,46 @@ export class Prompt< break; } - // Emit tool results for all completed tools. If any tool suspended, skip its result - // (the caller will supply it on resume) and break after processing the rest. - // request.messages ends with: [...history, assistantMsg(toolCalls), toolResult(completed)...] + // Emit tool results for every executor. The pairing guarantee: + // every assistant `tool_calls[i]` pushed by this block must + // have a matching `role: 'tool'` entry by the end of the loop, + // otherwise the next round-trip 400s (OpenAI / Anthropic both + // reject unpaired tool_calls). + // + // - `suspended`: skipped on purpose (suspend/resume relies on + // the missing slot to know which tool to resume). + // - incomplete (`ready` / `parsed` / `executing` / `interrupted` + // without an error string): no real content. With + // `toolsComplete: true` (default) we synthesize a marker + // (`[aborted: …]`, `[interrupted]`) so the model knows WHY + // the slot is empty. With `toolsComplete: false` we omit + // the message entirely — the caller is on their own. + // - complete (`success` / `error` / `invalid` / `interrupted` + // with an error string): emit the real result / error. let anySuspended = false; for (const toolExecutor of toolExecutors) { if (toolExecutor.status === 'suspended') { anySuspended = true; - continue; // Omit result — the pending result will be supplied on resume + continue; + } + const hasError = !!toolExecutor.error; + const hasResult = toolExecutor.result !== undefined && toolExecutor.result !== null; + const isComplete = hasError || hasResult || toolExecutor.status === 'success'; + if (!isComplete && !toolsComplete) { + // Opt-out — leave the unfinished tool_call without a + // paired result. Caller has accepted responsibility for + // handling the broken history shape (e.g. they're about + // to discard the request entirely, or they have their + // own retry logic). + continue; } - const content = toolExecutor.error - ? toolExecutor.error - : toolExecutor.result + const content = hasError + ? toolExecutor.error! + : hasResult ? typeof toolExecutor.result === 'string' ? toolExecutor.result : JSON.stringify(toolExecutor.result) - : ''; + : this.synthesizeUnpairedResult(toolExecutor); yield emitMessage({ role: 'tool', @@ -960,15 +1076,31 @@ export class Prompt< let errorMessage = ''; let resetReason = ''; + const errMax = this.input.validationErrorMaxLength; try { const parsedJSON = JSON.parse(potentialJSON); - const parsedSafe = await schema.safeParseAsync(parsedJSON); + // Apply the same strictify rewrite the provider used for the wire + // shape, so array-of-pairs records / numeric-key tuples / etc. + // normalize back into the natural Zod shape before validation. + // The descriptor was pinned on the response format when the + // provider built the request. Fast cache lookup on retries. + const responseDescriptorId = typeof request.responseFormat === 'object' + ? request.responseFormat.descriptor + : undefined; + const validationSchema = responseDescriptorId + ? strictify(schema, getDescriptorById(responseDescriptorId)) as typeof schema + : schema; + + const parsedSafe = await validationSchema.safeParseAsync(parsedJSON); if (!parsedSafe.success) { const issueSummary = parsedSafe.error.issues .map(i => `- ${i.path.join('.')}: ${i.message}${['string', 'boolean', 'number'].includes(typeof i.input) ? ` (input: ${i.input})` : ''}`) .join('\n') - errorMessage = `The output was an invalid format:\n${issueSummary}\n\nPlease adhere to the output schema:\n${toJSONSchema(schema, this.input.strict ?? true)}`; + errorMessage = this.truncateValidationError( + `The output was an invalid format:\n${issueSummary}`, + errMax, + ); resetReason = 'schema-parsing'; } else { result = parsedSafe.data as unknown as TOutput; @@ -976,12 +1108,18 @@ export class Prompt< try { await this.input.validate?.(result, ctx); } catch (validationError: any) { - errorMessage = `The output failed validation:\n${validationError.message}`; + errorMessage = this.truncateValidationError( + `The output failed validation:\n${validationError.message}`, + errMax, + ); resetReason = 'validation'; } } } catch (parseError: any) { - errorMessage = `The output was not valid JSON:\n${parseError.message}`; + errorMessage = this.truncateValidationError( + `The output was not valid JSON:\n${parseError.message}`, + errMax, + ); resetReason = 'json-parsing'; } @@ -1112,6 +1250,14 @@ export class Prompt< // We don't emit complete without a valid result unless toolsOnly is set if (result === undefined && !onlyTools) { + // Abort is not an error — the caller asked us to stop. Exit + // silently with the partial `request.messages` they may want + // for resume/inspection, without emitting `complete` (no real + // output to surface) and without raising. Mirrors the suspend + // path's `return undefined` semantics. + if (ctx.signal?.aborted) { + return undefined as any; + } if (!lastError && iterations === maxIterations) { lastError = `Maximum iterations (${maxIterations}) reached without a valid response.`; } @@ -1222,7 +1368,7 @@ export class Prompt< // Determine response format const responseFormat: ResponseFormat = schema && !(schema instanceof ZodString) - ? { type: schema as ZodType, strict: this.input.strict ?? true } + ? { type: schema as ZodType, strict: this.input.strict ?? 1 } : 'text'; return { config, content, tools, toolObjects, responseFormat, schema }; @@ -1247,6 +1393,68 @@ export class Prompt< }; } + /** + * Truncate a validation error so the corrective user message we send back + * to the LLM stays bounded. Lengthy zod errors against deep recursive + * schemas can run 100k+ characters — eating context and burning tokens + * on noise the model can't usefully act on. Anything past `max` is + * replaced with a `… (N more characters)` marker so the LLM both knows + * the message was clipped and roughly how much was lost. + * + * `protected` so a subclass can override — e.g. to emit a shorter + * marker, route errors through a custom formatter, or skip truncation + * entirely when targeting a model with a large context window. + */ + protected truncateValidationError(message: string, max?: number): string { + const cap = max ?? DEFAULT_VALIDATION_ERROR_MAX_LENGTH; + if (cap <= 0 || message.length <= cap) return message; + const dropped = message.length - cap; + return `${message.slice(0, cap)}… (${dropped} more characters)`; + } + + /** + * Build a short, model-readable placeholder for a `tool_call` that + * never produced a real result. Used by the result-emit loop when + * `toolsComplete` is true to keep `request.messages` well-paired + * even when the dispatch loop short-circuited (signal abort or + * `ToolInterrupt` cutting a parallel batch short) or a tool errored + * before its content could be accumulated. + * + * The marker prefix (`[aborted: …]`, `[interrupted]`, `[error: …]`) + * gives the model a clear cue and lets callers detect synthesized + * replies by inspecting the message content if they need to + * differentiate. Suspended tools are intentionally NOT routed + * through here — the suspend/resume protocol depends on the + * missing result slot. + * + * `protected` so a subclass can override — e.g. to emit + * project-specific markers, log diagnostics for every synthesized + * result, or fall back to a model-specific instruction string. + */ + protected synthesizeUnpairedResult>( + te: ToolExecution, + ): string { + switch (te.status) { + case 'interrupted': + return te.error + ? `[interrupted: ${te.error}]` + : '[interrupted]'; + case 'error': + case 'invalid': + return te.error + ? `[error: ${te.error}]` + : '[error]'; + case 'ready': + case 'parsed': + case 'executing': + // Never reached a terminal status — the dispatch loop cut out + // mid-flight (abort or interrupt of a sibling). + return '[aborted: tool call did not complete before the request was cancelled]'; + default: + return '[no result]'; + } + } + /** * Trims messages from the request to fit within token limits. * @@ -1413,7 +1621,17 @@ function emitter() { return emitter; } -function newToolExecution(ctx: Context, toolCall: ToolCall, toolInfo?: { tool: T, definition: ToolDefinition }) { +function newToolExecution( + ctx: Context, + toolCall: ToolCall, + toolInfo?: { tool: T, definition: ToolDefinition }, + validationErrorMaxLength?: number, + // Pluggable truncator — the owning Prompt instance forwards its + // (overridable) `truncateValidationError` method here so a subclass + // can change how tool-arg parse errors are formatted without having + // to fork the whole prompt loop. + truncate?: (message: string, max?: number) => string, +) { const start = emitter(); const output = emitter(); const error = emitter(); @@ -1440,14 +1658,34 @@ function newToolExecution(ctx: Context, toolCall: T if (execution.status !== 'ready') { return execution; } - const args = execution.toolCall.arguments || '{}'; + const rawArgs = execution.toolCall.arguments; + const isEmpty = !rawArgs || rawArgs.trim() === ''; + const args = isEmpty ? '{}' : rawArgs; try { - execution.args = await toolInfo!.tool.parse(ctx, args, toolInfo!.definition.parameters); + execution.args = await toolInfo!.tool.parse( + ctx, + args, + toolInfo!.definition.parameters, + toolInfo!.definition.descriptor, + ); execution.status = 'parsed'; start.ready = true; } catch (e: any) { execution.status = 'invalid'; - execution.error = `Error parsing tool arguments: ${e.message}, args: ${args}`; + // Distinguish "model sent no arguments at all" from "model sent + // bad arguments". The former is usually a streaming-relay issue + // (OpenRouter/Anthropic) or a genuine model gaffe — calling it + // out by name in the error gives the model a clearer cue to + // fix itself on the retry turn instead of repeating the empty + // call. The latter (bad arguments) keeps its original Zod / + // JSON.parse message, which already names the offending field. + const reason = isEmpty + ? `the tool was called with NO arguments. The schema requires arguments — re-call this tool with the required fields populated. Validation: ${e.message}` + : `${e.message}, args: ${args}`; + const formatted = `Error parsing tool arguments: ${reason}`; + execution.error = truncate + ? truncate(formatted, validationErrorMaxLength) + : formatted; error.ready = true; } @@ -1482,4 +1720,6 @@ function newToolExecution(ctx: Context, toolCall: T }; return execution; -}; \ No newline at end of file +}; + + diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index f20e6cbb..89e91888 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,15 +1,465 @@ import z from 'zod'; +/** + * Provider family for a `FormatDescriptor`. The three known wire dialects + * (`'openai'`, `'anthropic'`, `'google'`) are autocomplete hints; any string + * is accepted so users can register custom descriptors via + * `registerDescriptor` and reference them by family name in + * `ModelInfo.strictFormat`. `'lenient'` is the no-rewrite catch-all and is + * not registerable as a custom family. + * + * Use this type wherever you need to talk about a descriptor family — + * registry functions, model declarations, etc. + */ +export type DescriptorFamily = 'openai' | 'anthropic' | 'google' | (string & {}); + +/** + * Format descriptor — describes the JSON Schema dialect of a target. + * + * Each provider/strict combination is represented by one descriptor. The + * descriptor controls both how `strictify` rewrites a Zod schema (preprocesses + * for record-as-array, tuple-as-object, etc.) and how `toJSONSchema` emits the + * matching JSON Schema. Every `if (strict)` decision in the conversion + * pipeline dispatches off a descriptor field, so adding a new dialect is a + * matter of declaring a new `FormatDescriptor` and registering it. + */ +export interface FormatDescriptor { + /** Stable id used as the cache key inside `strictify`. */ + readonly id: string; + /** + * Provider family for this descriptor. The three built-in wire dialects + * are `'openai'` / `'anthropic'` / `'google'`; `'lenient'` is the + * no-rewrite catch-all. Custom descriptors registered via + * `registerDescriptor` may use any string here — the family is the + * lookup key used by `getDescriptor(family, strict)`. + */ + readonly family: DescriptorFamily | 'lenient'; + /** True if this descriptor represents the strict variant for its family. */ + readonly strict: boolean; + + // ---- Object encoding ---- + /** When true, every object field appears in `required[]` (OpenAI strict). */ + readonly objectAllFieldsRequired: boolean; + /** When true, every object emits `additionalProperties: false`. */ + readonly objectClosedByDefault: boolean; + + // ---- Record encoding ---- + /** How to encode `z.record(K, V)`. */ + readonly recordEncoding: 'array-of-pairs' | 'open-record'; + + // ---- Tuple encoding ---- + /** + * How to encode `z.tuple([...])`. + * - `object-numeric-keys`: OpenAI-strict workaround (`{ "0": A, "1": B, ... }`) + * - `prefix-items`: standard JSON Schema `prefixItems` (Anthropic / Google) + * - `items-union`: collapse to homogeneous `items: { anyOf: [...] }` (last-resort) + */ + readonly tupleEncoding: 'object-numeric-keys' | 'prefix-items' | 'items-union'; + + // ---- Combinators ---- + readonly allowAllOf: boolean; + readonly allowAnyOf: boolean; + readonly allowOneOf: boolean; + + // ---- Refs ---- + /** Whether `{ $ref: '#' }` self-reference is permitted. */ + readonly allowRootRef: boolean; + readonly allowDefsRef: boolean; + /** Reserved: emit Google-style `propertyOrdering` hints. */ + readonly emitPropertyOrdering: boolean; + + // ---- String formats ---- + /** Whitelist of `format:` values to emit, or `'all'` to pass through. */ + readonly supportedStringFormats: ReadonlySet | 'all'; + readonly allowPattern: boolean; + readonly allowMultiplePatterns: boolean; + + // ---- Length / numeric constraints ---- + readonly allowMinMaxLength: boolean; + readonly allowMinMaxItems: boolean; + readonly allowMinimumMaximum: boolean; + + // ---- Optional → nullable rewrite ---- + /** When true, `optional` becomes `T|null` (because every prop must be required). */ + readonly optionalAsNullable: boolean; + + // ---- "Any" schema encoding ---- + /** + * How `z.any()` / `z.unknown()` is encoded. + * - `recursive-strict`: self-referencing $defs/Any with array-of-pairs records + * (OpenAI strict has no open-object support) + * - `recursive-open`: $defs/Any with `additionalProperties: ` records + */ + readonly anyEncoding: 'recursive-strict' | 'recursive-open'; + + // ---- Per-request strict-mode budget ---- + // Limits enforced by the API across ALL strict tools + structured-output + // schemas in a single request. `undefined` means no documented cap. The + // SchemaBudget tracks remaining slots and degrades over-budget items to + // LENIENT silently rather than failing the whole call. + /** Maximum number of tools that can carry `strict: true` in one request. */ + readonly maxStrictTools?: number; + /** Maximum total optional parameters across all strict schemas in one request. */ + readonly maxStrictOptionalParams?: number; + /** Maximum total union-type parameters across all strict schemas in one request. */ + readonly maxStrictUnionTypes?: number; + + // ---- Schema-feature feasibility ---- + /** + * Whether the descriptor can express recursive schemas (z.lazy / $ref to + * self). Anthropic strict: false. OpenAI / Google strict: true. Used by the + * SchemaBudget to mark recursive items as infeasible under non-supporting + * descriptors and degrade them to LENIENT. + */ + readonly supportsRecursion: boolean; +} + +const OPENAI_STRICT_FORMATS = new Set([ + 'date-time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid', +]); + +/** Lenient descriptor — no rewrites, no closure, formats pass through. Default for unsupported models. */ +export const LENIENT: FormatDescriptor = Object.freeze({ + id: 'lenient', + family: 'lenient', + strict: false, + objectAllFieldsRequired: false, + objectClosedByDefault: false, + recordEncoding: 'open-record', + tupleEncoding: 'prefix-items', + allowAllOf: true, + allowAnyOf: true, + allowOneOf: true, + allowRootRef: true, + allowDefsRef: true, + emitPropertyOrdering: false, + supportedStringFormats: 'all', + allowPattern: true, + allowMultiplePatterns: true, + allowMinMaxLength: true, + allowMinMaxItems: true, + allowMinimumMaximum: true, + optionalAsNullable: false, + anyEncoding: 'recursive-open', + supportsRecursion: true, +}); + +/** + * OpenAI strict — current behavior verbatim. Records→pairs, + * tuples→numeric-keys, optional→nullable. + * + * No documented per-request slot limit (max strict tools / optional params / + * union types). The SchemaBudget treats those caps as `undefined` (no limit) + * for OpenAI; OpenAI's own ~5000-property and ~5-level-depth schema caps + * aren't enforced here either — they're rare in practice and produce a + * server-side rejection that the user can react to. + */ +export const OPENAI_STRICT: FormatDescriptor = Object.freeze({ + id: 'openai-strict', + family: 'openai', + strict: true, + objectAllFieldsRequired: true, + objectClosedByDefault: true, + recordEncoding: 'array-of-pairs', + tupleEncoding: 'object-numeric-keys', + allowAllOf: false, + allowAnyOf: true, + allowOneOf: false, + allowRootRef: true, + allowDefsRef: true, + emitPropertyOrdering: false, + supportedStringFormats: OPENAI_STRICT_FORMATS, + allowPattern: true, + allowMultiplePatterns: false, + allowMinMaxLength: false, + allowMinMaxItems: false, + allowMinimumMaximum: false, + optionalAsNullable: true, + anyEncoding: 'recursive-strict', + supportsRecursion: true, +}); + +/** OpenAI non-strict — alias of LENIENT but tagged with the openai family. */ +export const OPENAI_NON_STRICT: FormatDescriptor = Object.freeze({ + ...LENIENT, + id: 'openai-non-strict', + family: 'openai', +}); + +/** + * Anthropic strict — Claude 4.5+ only (Opus 4.7/4.6/4.5, Sonnet 4.6/4.5, + * Haiku 4.5). Per Anthropic's structured-outputs docs: + * + * - `additionalProperties` may **only** be `false` (no schema-valued open + * records), so `z.record(...)` falls back to the OpenAI-style + * array-of-pairs workaround. + * - Recursive schemas are **not** supported. `allowRootRef`/`allowDefsRef` + * are both `false`; if a Zod schema is recursive, the provider should + * downgrade that request to LENIENT (toJSONSchema will still emit `$ref`, + * but the Anthropic API will reject it). + * - Numerical (`minimum`/`maximum`/`multipleOf`) and string-length + * (`minLength`/`maxLength`) constraints are not supported and are dropped. + * - `prefixItems` and other positional tuple keywords are not in the + * supported list, so tuples collapse to a homogeneous `items: anyOf`. + * - Supported formats include `uri` (unlike OpenAI strict). We pass all + * formats through and let Anthropic ignore unknowns. + */ +export const ANTHROPIC_STRICT: FormatDescriptor = Object.freeze({ + id: 'anthropic-strict', + family: 'anthropic', + strict: true, + objectAllFieldsRequired: false, + objectClosedByDefault: true, + recordEncoding: 'array-of-pairs', + tupleEncoding: 'items-union', + allowAllOf: true, + allowAnyOf: true, + allowOneOf: false, + allowRootRef: false, + allowDefsRef: false, + emitPropertyOrdering: false, + supportedStringFormats: 'all', + allowPattern: true, + allowMultiplePatterns: false, + allowMinMaxLength: false, + allowMinMaxItems: false, + allowMinimumMaximum: false, + optionalAsNullable: false, + anyEncoding: 'recursive-strict', + // Anthropic-documented per-request limits (apply across ALL strict tool + // schemas + JSON output schemas in one request). + // Source: https://platform.claude.com/docs/en/build-with-claude/structured-outputs + maxStrictTools: 20, + maxStrictOptionalParams: 24, + maxStrictUnionTypes: 16, + // Recursive schemas are explicitly NOT supported under Anthropic strict. + // The SchemaBudget detects recursion in source schemas and degrades them + // to LENIENT silently rather than emitting a $ref Anthropic will reject. + supportsRecursion: false, +}); + +export const ANTHROPIC_NON_STRICT: FormatDescriptor = Object.freeze({ + ...LENIENT, + id: 'anthropic-non-strict', + family: 'anthropic', +}); + +/** + * Google Gemini strict — per `ai.google.dev/gemini-api/docs/structured-output`: + * + * - Supported keywords: types, `properties`, `required`, `additionalProperties`, + * `enum`, `format` (date-time/date/time only), `minimum`, `maximum`, `items`, + * `prefixItems`, `minItems`, `maxItems`, `title`, `description`, + * `propertyOrdering`. Recursion via `$ref: "#"` (root self-ref). + * - Combinators (`allOf`, `anyOf`, `oneOf`) are NOT in the supported list; + * we conservatively flag them off so unions degrade to a representable form + * rather than emit something Gemini ignores. + * - String constraints (`minLength`, `maxLength`, `pattern` formats beyond the + * three documented) are not supported. + * - `propertyOrdering` is REQUIRED for Gemini 2.0 strict; we always emit it + * on object schemas under this descriptor. + */ +export const GOOGLE_STRICT: FormatDescriptor = Object.freeze({ + id: 'google-strict', + family: 'google', + strict: true, + objectAllFieldsRequired: false, + objectClosedByDefault: false, + recordEncoding: 'open-record', + tupleEncoding: 'prefix-items', + allowAllOf: false, + allowAnyOf: false, + allowOneOf: false, + allowRootRef: true, + allowDefsRef: false, + emitPropertyOrdering: true, + supportedStringFormats: new Set(['date-time', 'date', 'time']), + allowPattern: false, + allowMultiplePatterns: false, + allowMinMaxLength: false, + allowMinMaxItems: true, + allowMinimumMaximum: true, + optionalAsNullable: false, + anyEncoding: 'recursive-open', + // No documented per-request slot limits. + supportsRecursion: true, +}); + +export const GOOGLE_NON_STRICT: FormatDescriptor = Object.freeze({ + ...LENIENT, + id: 'google-non-strict', + family: 'google', +}); + +// ============================================================================ +// Descriptor registry — mutable lookup tables for built-in + user-registered +// descriptors. `registerDescriptor` adds to both maps; `getDescriptor` / +// `getDescriptorById` consult them on every lookup so newly-registered +// descriptors are immediately addressable by family or id. +// ============================================================================ + +const DESCRIPTORS_BY_ID = new Map(); +// Keyed by family name. Each family slot holds its strict and lenient variants. +// `'lenient'` always points to `LENIENT` for both keys. +const DESCRIPTORS_BY_FAMILY = new Map(); + +function indexDescriptor(d: FormatDescriptor): void { + DESCRIPTORS_BY_ID.set(d.id, d); + const slot = DESCRIPTORS_BY_FAMILY.get(d.family) ?? {}; + if (d.strict) slot.strict = d; + else slot.lenient = d; + DESCRIPTORS_BY_FAMILY.set(d.family, slot); +} + +// Seed the registry with built-ins. +for (const d of [ + LENIENT, + OPENAI_STRICT, OPENAI_NON_STRICT, + ANTHROPIC_STRICT, ANTHROPIC_NON_STRICT, + GOOGLE_STRICT, GOOGLE_NON_STRICT, +]) { + indexDescriptor(d); +} + +/** + * Register a custom `FormatDescriptor` so it can be looked up by `id` or + * by `(family, strict)`. Useful for adding support for a new provider + * dialect or for registering a tweaked variant of an existing family. + * + * The descriptor's `id` and `family` become the lookup keys. If a + * descriptor with the same id is already registered, the new one + * replaces it. The family slot tracks one strict and one lenient + * variant; registering a third variant overwrites the matching slot. + * + * Built-in descriptors (`OPENAI_STRICT`, `LENIENT`, etc.) are seeded at + * module load — you don't need to register them manually. They CAN be + * overridden by registering a same-id descriptor afterwards, but doing + * so is generally a sign that you should pick a different id. + * + * @example + * ```ts + * import { registerDescriptor, OPENAI_STRICT } from '@aeye/core'; + * + * // A tighter OpenAI-flavored descriptor: same wire shape but with + * // pattern support disabled (some downstream tools choke on regex). + * registerDescriptor({ + * ...OPENAI_STRICT, + * id: 'openai-no-regex', + * family: 'openai-no-regex', + * allowPattern: false, + * allowMultiplePatterns: false, + * }); + * + * // Now resolvable: + * getDescriptor('openai-no-regex', true); // → the registered descriptor + * getDescriptorById('openai-no-regex'); // → same + * ``` + */ +export function registerDescriptor(descriptor: FormatDescriptor): void { + indexDescriptor(descriptor); +} + +/** + * Resolve a descriptor by family + strictness. + * + * Built-in families (`'openai'`, `'anthropic'`, `'google'`) always + * resolve. Custom families registered via `registerDescriptor` resolve + * once they're registered. Unknown families fall back to `LENIENT` + * (safe default — strict mode silently degrades for unrecognized models). + * + * @param family - provider family (built-in or registered) + * @param strict - whether the strict variant is wanted + */ +export function getDescriptor(family: DescriptorFamily, strict: boolean): FormatDescriptor { + if (!strict) { + const slot = DESCRIPTORS_BY_FAMILY.get(family); + return slot?.lenient ?? LENIENT; + } + const slot = DESCRIPTORS_BY_FAMILY.get(family); + return slot?.strict ?? LENIENT; +} + +/** + * Look up a descriptor by id. Returns LENIENT for unknown ids (safe default). + * Custom descriptors registered via `registerDescriptor` are reachable here + * by their `id`. + */ +export function getDescriptorById(id: string | undefined): FormatDescriptor { + if (!id) return LENIENT; + return DESCRIPTORS_BY_ID.get(id) ?? LENIENT; +} + +/** + * True if `family` has at least one registered descriptor (strict or + * lenient). Used by `@aeye/ai`'s `resolveStrictFormat` fallback chain to + * decide whether a model id prefix or provider name is a recognized + * dialect. + */ +export function hasDescriptorFamily(family: string | undefined): boolean { + if (family === undefined) return false; + return DESCRIPTORS_BY_FAMILY.has(family); +} + +/** + * Resolve descriptor from various input shapes (boolean, options object, or descriptor). + * Used by `toJSONSchema` to support its overloaded signature. + */ +export function resolveDescriptor( + input: FormatDescriptor | ToJSONSchemaOptions | boolean | undefined, +): FormatDescriptor { + if (input === undefined) return OPENAI_STRICT; + if (typeof input === 'boolean') return input ? OPENAI_STRICT : LENIENT; + if ('id' in input && 'family' in input) return input as FormatDescriptor; + const opts = input as ToJSONSchemaOptions; + return getDescriptor(opts.format ?? 'openai', opts.strict); +} type StrictTransformer = (schema: z.ZodType | z.core.$ZodType) => z.ZodType; /** -* Recursively transforms a Zod schema to support strict mode. -* -* @param schema - input Zod schema -* @returns transformed Zod schema -*/ -export function strictify(schema: S): S { + * Module-scope cache. Per-source-schema entry survives only as long as the + * source schema does — the WeakMap entry is gc'd with it, so all per-format + * strictified clones go with it. The inner Map is keyed by descriptor.id + * (small bounded set) so it cannot grow unboundedly. + */ +const strictifyCache = new WeakMap>(); + +/** + * Recursively transforms a Zod schema to a target dialect. + * + * For LENIENT, returns the input schema unchanged (no rewrites). For strict + * descriptors, installs preprocesses that accept the dialect's wire shape + * (e.g. array-of-pairs records, numeric-key tuple objects) and normalize them + * back to the natural Zod shape before validation. + * + * Repeated calls with the same `(schema, descriptor)` return the cached + * transformed schema. Different descriptors share the same outer entry but + * map to different inner values — bounded by the small descriptor count. + * + * @param schema - input Zod schema + * @param descriptor - target dialect descriptor (defaults to OPENAI_STRICT) + */ +export function strictify(schema: S, descriptor: FormatDescriptor = OPENAI_STRICT): S { + // LENIENT is a no-op: same reference, no cache entry needed. + if (descriptor.id === LENIENT.id) return schema; + + let perSchema = strictifyCache.get(schema); + if (!perSchema) { + perSchema = new Map(); + strictifyCache.set(schema, perSchema); + } + const cached = perSchema.get(descriptor.id); + if (cached) return cached as S; + + const result = strictifyWithDescriptor(schema, descriptor); + perSchema.set(descriptor.id, result); + return result as S; +} + +function strictifyWithDescriptor(schema: S, descriptor: FormatDescriptor): S { + // Per-call cycle map. Same scheme as before: cache lazy-thunks for in-flight + // schemas so recursive references emit a `z.lazy(() => result)` rather than + // recursing forever. const map = new Map z.ZodType)>(); const transform: StrictTransformer = (s) => { @@ -23,7 +473,7 @@ export function strictify(schema: S): S { } let result: z.ZodType; map.set(s, () => result); - result = strictifySimple(s, transform); + result = strictifySimple(s, transform, descriptor); map.set(s, result); return result; }; @@ -33,10 +483,6 @@ export function strictify(schema: S): S { /** * Transfer description and metadata from source Zod schema to target Zod schema -* -* @param target - target Zod schema -* @param source - source Zod schema -* @returns */ function transferMetadata(target: z.ZodType, source: z.ZodType) { if (source.description) { @@ -50,48 +496,27 @@ function transferMetadata(target: z.ZodType, source: z.ZodType) { } /** - * Extracts the input schema from a transformed schema (codec or preprocess). - * For preprocess with optional, we need the nullable version for strict mode input. - * This is needed when building input schemas that contain transformed fields. + * Per-node strictify dispatch. Each branch consults the descriptor to decide + * whether to install the dialect-specific preprocess. */ -function getInputSchema(schema: z.ZodType): z.ZodType { - if (schema instanceof z.ZodCodec) { - return schema.def.in as z.ZodType; - } - // For ZodPipe (which preprocess creates), check if the output is optional - // If so, we need to return a nullable version for the input schema - if (schema instanceof z.ZodPipe) { - const outputSchema = schema.def.out; - if (outputSchema instanceof z.ZodOptional) { - // The input from preprocess is already set up to handle null->undefined conversion - // So we just need to make sure the base type accepts null - return z.nullable(outputSchema.unwrap()); - } - } - return schema; -} - -/** -* Recursively transforms a Zod schema to support strict mode. -* -* @param schema - input Zod schema -* @returns transformed Zod schema -*/ function strictifySimple( schema: z.ZodType | z.core.$ZodType, transform: StrictTransformer, + descriptor: FormatDescriptor, ): z.ZodType { - // Handle ZodOptional - get the inner schema and make it optional + // Handle ZodOptional if (schema instanceof z.ZodOptional) { const innerSchema = schema.unwrap(); const transformed = transform(innerSchema); - // Check if the inner schema is nullable - // If it's nullable, don't convert null to undefined - const isNullable = innerSchema instanceof z.ZodNullable; + // Only install the null→undefined preprocess for dialects that rewrite + // optional to nullable on the wire (OpenAI strict). For other dialects, + // optional stays optional and the preprocess is unnecessary. + if (!descriptor.optionalAsNullable) { + return transferMetadata(transformed.optional() as z.ZodType, schema); + } - // Use a preprocess to convert null to undefined only for non-nullable schemas - // This avoids issues with codec output validation in recursive schemas + const isNullable = innerSchema instanceof z.ZodNullable; return transferMetadata( z.preprocess( (val) => (!isNullable && val === null) ? undefined : val, @@ -108,7 +533,7 @@ function strictifySimple( transformedShape[key] = transform(schema.shape[key]); } return transferMetadata( - z.object(transformedShape), + z.object(transformedShape), schema ); } @@ -138,86 +563,139 @@ function strictifySimple( schema ); } - + // Handle ZodNullable if (schema instanceof z.ZodNullable) { return transferMetadata( - transform(schema.unwrap()).nullable(), + transform(schema.unwrap()).nullable(), schema ); } - + // Handle ZodArray if (schema instanceof z.ZodArray) { return transferMetadata( - z.array(transform(schema.element)), + z.array(transform(schema.element)), schema ); } - + // Handle ZodRecord + // Strict-mode JSON Schema represents records as `array of {key, value}` — + // OpenAI's structured outputs has no native pattern for open records, so + // we rewrite. Previously this used `z.codec(arrayIn, recordOut, decode)`, + // but z.codec proved fragile inside recursive lazy unions: the inner + // codec's transform doesn't always run when validation traverses a + // sibling branch, leaving the data in array form when later code expects + // record form (and vice-versa) — surfacing as "expected array, received + // object" union failures. + // + // `z.preprocess` is more robust here: it normalizes the input to record + // shape BEFORE the record schema sees it. Either array-of-pairs (from a + // strict-mode model) or already-a-record (when the schema is reused + // outside strict context) is accepted; both arrive at the record schema + // as a record. + // + // Only installed when the descriptor wants the array-of-pairs encoding. if (schema instanceof z.ZodRecord) { const keyTransformed = schema.keyType ? transform(schema.keyType) as z.ZodType : z.string(); const valueTransformed = transform(schema.valueType); - // For the input schema (array of {key, value}), use the input side of any codecs - const key = getInputSchema(keyTransformed); - const value = getInputSchema(valueTransformed); + if (descriptor.recordEncoding !== 'array-of-pairs') { + return transferMetadata( + z.record(keyTransformed, valueTransformed), + schema, + ); + } return transferMetadata( - z.codec( - z.array(z.object({ key, value })), - z.record(keyTransformed, valueTransformed), - { - decode: (arr) => { + z.preprocess( + (val) => { + if (Array.isArray(val)) { const record: Record = {}; - for (const { key, value } of arr) { - record[key as PropertyKey] = value; + for (const entry of val) { + if (entry && typeof entry === 'object' && 'key' in entry && 'value' in entry) { + record[(entry as { key: PropertyKey }).key] = (entry as { value: unknown }).value; + } } return record; - }, - encode: (rec) => Object.entries(rec).map( - ([key, value]) => ({ key, value }) - ), - } + } + return val; + }, + z.record(keyTransformed, valueTransformed), ), schema ); } - + // Handle ZodUnion if (schema instanceof z.ZodUnion) { return transferMetadata( - z.union(schema.options.map(transform) as [z.ZodType, ...z.ZodType[]]), + z.union(schema.options.map(transform) as [z.ZodType, ...z.ZodType[]]), schema ); } - + // Handle ZodDiscriminatedUnion if (schema instanceof z.ZodDiscriminatedUnion) { return transferMetadata( - z.discriminatedUnion(schema.def.discriminator, schema.options.map(transform) as [any, ...any[]]), + z.discriminatedUnion(schema.def.discriminator, schema.options.map(transform) as [any, ...any[]]), schema ); } - + // Handle ZodIntersection if (schema instanceof z.ZodIntersection) { return transferMetadata( - z.intersection(transform(schema.def.left), transform(schema.def.right)), + z.intersection(transform(schema.def.left), transform(schema.def.right)), schema ); } - + // Handle ZodTuple + // Strict-mode JSON Schema represents tuples as an object with numeric + // string keys (`{"0": , "1": , ...}`) — OpenAI's structured + // outputs has no positional `prefixItems` support and would otherwise + // collapse a `[string, number, bool]` to an array of `(string|number|bool)`, + // losing the per-position type. Encoding as an object preserves it. + // The strictified schema accepts EITHER form: an object with "0".."n-1" + // keys (what a strict-mode model produces) or an array (what a callsite + // outside strict context would pass). Both arrive at the tuple schema as + // an array. + // + // Only installed when the descriptor wants the numeric-keys encoding. if (schema instanceof z.ZodTuple) { + const items = schema.def.items.map(transform) as [z.ZodType, ...z.ZodType[]]; + const rest = schema.def.rest ? transform(schema.def.rest) : undefined; + const tupleSchema = rest ? z.tuple(items, rest) : z.tuple(items); + + if (descriptor.tupleEncoding !== 'object-numeric-keys') { + return transferMetadata(tupleSchema, schema); + } + return transferMetadata( - z.tuple(schema.def.items.map(transform) as [z.ZodType, ...z.ZodType[]]), - schema + z.preprocess( + (val) => { + if (val && typeof val === 'object' && !Array.isArray(val)) { + const obj = val as Record; + const keys = Object.keys(obj); + if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) { + const indices = keys.map((k) => parseInt(k, 10)); + const len = Math.max(...indices) + 1; + const arr: unknown[] = new Array(len); + for (const k of keys) arr[parseInt(k, 10)] = obj[k]; + return arr; + } + } + return val; + }, + tupleSchema, + ), + schema, ); } - - // Handle ZodEffects (transforms, refines, etc.) + + // Handle ZodDefault if (schema instanceof z.ZodDefault) { return z.preprocess( (val) => val === null ? undefined : val, @@ -227,15 +705,15 @@ function strictifySimple( ), ); } - + // Handle ZodLazy if (schema instanceof z.ZodLazy) { return transferMetadata( - z.lazy(() => transform(schema.def.getter())), + z.lazy(() => transform(schema.def.getter())), schema ); } - + // For all other types (primitives, etc.), return as-is return schema as z.ZodType; } @@ -243,10 +721,17 @@ function strictifySimple( /** * Format specification for JSON Schema generation */ -export type JSONSchemaFormat = 'openai'; +/** + * Family name for `ToJSONSchemaOptions.format`. Alias of `DescriptorFamily` + * — the three built-in dialects (`'openai'`, `'anthropic'`, `'google'`) + * plus any string registered via `registerDescriptor`. + * + * @deprecated Prefer `DescriptorFamily`. Both refer to the same widened type. + */ +export type JSONSchemaFormat = DescriptorFamily; /** - * Options for toJSONSchemaV2 + * Options for toJSONSchema */ export interface ToJSONSchemaOptions { /** @@ -254,10 +739,11 @@ export interface ToJSONSchemaOptions { */ strict: boolean; /** - * The target format for the JSON Schema. Currently only 'openai' is supported. - * Different formats may have different constraints and supported features. + * The target format for the JSON Schema. Resolves to a named FormatDescriptor. + * Built-in (`'openai'` / `'anthropic'` / `'google'`) or any string + * registered via `registerDescriptor`. */ - format?: JSONSchemaFormat; + format?: DescriptorFamily; } export type JSONSchemaType = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'null' | 'integer'; @@ -293,6 +779,8 @@ export interface JSONSchema { const?: any; title?: string; $defs?: Record; + /** Gemini-specific: deterministic property emission order for strict mode. */ + propertyOrdering?: string[]; [metadata: string]: unknown; } @@ -301,8 +789,7 @@ export interface JSONSchema { */ interface ConversionContext { root: z.ZodType, - strict: boolean; - format: JSONSchemaFormat; + descriptor: FormatDescriptor; definitions: Map; // schema to [js, id] definitionSchemas: Map; // id to schema refCounter: number; @@ -310,14 +797,14 @@ interface ConversionContext { } /** - * Converts a Zod schema to JSON Schema V2 with support for different provider formats. + * Converts a Zod schema to JSON Schema with support for different provider dialects. * - * This is a custom implementation that recursively inspects Zod schemas and converts them - * to JSON Schema following the pattern of strictify/strictifySimple. + * Accepts either a boolean (legacy: true = OPENAI_STRICT, false = LENIENT), + * an options object `{ strict, format? }`, or a `FormatDescriptor` directly. * * @param schema - The Zod schema to convert * @param options - Configuration options for the conversion - * @returns JSON Schema object compatible with the specified format + * @returns JSON Schema object compatible with the resolved dialect * * @example * ```typescript @@ -327,26 +814,21 @@ interface ConversionContext { * }); * * // For OpenAI strict mode - * const strictSchema = strictify(schema); - * const jsonSchema = toJSONSchemaV2(strictSchema, { strict: true, format: 'openai' }); + * const jsonSchema = toJSONSchema(schema, { strict: true, format: 'openai' }); + * + * // For Anthropic strict mode + * const anthropic = toJSONSchema(schema, ANTHROPIC_STRICT); * ``` */ export function toJSONSchema( - schema: z.ZodType, - options: ToJSONSchemaOptions | boolean + schema: z.ZodType, + options: ToJSONSchemaOptions | boolean | FormatDescriptor, ): JSONSchema { - const resolvedOptions = typeof options === 'boolean' ? { strict: options } : options; - const { strict, format = 'openai' } = resolvedOptions; - - // Currently only OpenAI format is supported - if (format !== 'openai') { - throw new Error(`Unsupported format: ${format}. Currently only 'openai' is supported.`); - } + const descriptor = resolveDescriptor(options); const context: ConversionContext = { root: schema, - strict, - format, + descriptor, definitions: new Map(), definitionSchemas: new Map(), refCounter: 0, @@ -377,7 +859,7 @@ function convert(schema: z.ZodType | z.core.$ZodType, context: ConversionContext // Check cache FIRST before evaluating getter to prevent infinite recursion const [cachedJs, cachedId] = context.definitions.get(schema) || []; if (cachedJs && cachedId) { - if (schema === context.root) { + if (schema === context.root && context.descriptor.allowRootRef) { return { $ref: `#` }; } if (!context.definitionSchemas.has(cachedId)) { @@ -400,7 +882,7 @@ function convert(schema: z.ZodType | z.core.$ZodType, context: ConversionContext const cachedMeta = cachedSchema.meta(); if (cachedMeta?.aid === aid && jsId && js) { // Found a cached version with the same aid - use it - if (schema === context.root) { + if (schema === context.root && context.descriptor.allowRootRef) { return { $ref: `#` }; } @@ -423,7 +905,7 @@ function convert(schema: z.ZodType | z.core.$ZodType, context: ConversionContext // Check cache by object identity for non-lazy schemas const [js, jsId] = context.definitions.get(schema) || []; if (jsId && js) { - if (schema === context.root) { + if (schema === context.root && context.descriptor.allowRootRef) { return { $ref: `#` }; } @@ -486,6 +968,8 @@ function convert(schema: z.ZodType | z.core.$ZodType, context: ConversionContext * Main conversion function */ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionContext): JSONSchema { + const descriptor = context.descriptor; + // TODO: Map, Set, File, ReadOnly, Nan, Catch, Prefault, NonOptional, Transform, Function, Promise, Custom // Handle ZodOptional @@ -493,11 +977,11 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC const innerSchema = schema.unwrap(); const innerJson = convert(innerSchema, context); - if (context.strict) { - // In strict mode, optional fields become nullable - return makeNullable(innerJson); + if (descriptor.optionalAsNullable) { + // OpenAI-strict-style: optional fields become nullable on the wire. + return makeNullable(innerJson, descriptor); } else { - // In non-strict mode, optional fields are just not required + // Standard JSON Schema: optional fields are simply not in `required[]`. return innerJson; } } @@ -506,7 +990,7 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC if (schema instanceof z.ZodNullable) { const innerSchema = schema.unwrap(); const innerJson = convert(innerSchema, context); - return makeNullable(innerJson); + return makeNullable(innerJson, descriptor); } // Handle ZodObject @@ -517,11 +1001,11 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC for (const key in shape) { const fieldSchema = shape[key]; - const isRequired = context.strict || !isOptional(fieldSchema); - + const isFieldRequired = descriptor.objectAllFieldsRequired || !isOptional(fieldSchema); + properties[key] = convert(fieldSchema, context); - if (isRequired) { + if (isFieldRequired) { required.push(key); } } @@ -532,12 +1016,20 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC required, }; - if (context.strict || schema.def.catchall?._zod.def.type === "never") { + if (descriptor.objectClosedByDefault || schema.def.catchall?._zod.def.type === "never") { result.additionalProperties = false; } else if (schema.def.catchall) { result.additionalProperties = convert(schema.def.catchall, context); } + // Gemini 2.0 strict requires `propertyOrdering` so the model emits keys + // in a deterministic order. Other dialects ignore this hint, so emitting + // it under non-Google descriptors is harmless — but we gate it on the + // descriptor flag to keep the JSON Schema minimal everywhere else. + if (descriptor.emitPropertyOrdering) { + result.propertyOrdering = Object.keys(properties); + } + return result; } @@ -547,20 +1039,22 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC type: 'array', items: convert(schema.element, context), }; - const { minimum, maximum } = schema._zod.bag; - if (typeof minimum === 'number') { - result.minItems = minimum; - } - if (typeof maximum === 'number') { - result.maxItems = maximum; + if (descriptor.allowMinMaxItems) { + const { minimum, maximum } = schema._zod.bag; + if (typeof minimum === 'number') { + result.minItems = minimum; + } + if (typeof maximum === 'number') { + result.maxItems = maximum; + } } return result; } - // Handle ZodRecord - convert to array of {key, value} pairs in strict mode + // Handle ZodRecord if (schema instanceof z.ZodRecord) { - if (context.strict) { - // In strict mode, records become arrays of {key, value} objects + if (descriptor.recordEncoding === 'array-of-pairs') { + // Strict-mode workaround: records become arrays of {key, value} objects. return { type: 'array', items: { @@ -573,14 +1067,14 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC additionalProperties: false, }, }; - } else { - // In non-strict mode, use additionalProperties - return { - type: 'object', - propertyNames: convert(schema.keyType, context), - additionalProperties: convert(schema.valueType, context), - }; } + + // Standard: open record with propertyNames + additionalProperties. + return { + type: 'object', + propertyNames: convert(schema.keyType, context), + additionalProperties: convert(schema.valueType, context), + }; } // Handle ZodUnion @@ -595,7 +1089,7 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC return { anyOf }; } - // Handle ZodIntersection - not supported in OpenAI strict mode, convert to anyOf + // Handle ZodIntersection if (schema instanceof z.ZodIntersection) { const left = convert(schema.def.left, context); const right = convert(schema.def.right, context); @@ -604,34 +1098,55 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC ...(right.allOf && Object.keys(right).length === 1 ? right.allOf! : [right]), ]; - // TODO merge types to support allOf directly for strict mode - return context.strict ? { anyOf: allOf } : { allOf }; + // OpenAI strict bans allOf in some shapes; collapse to anyOf when the + // descriptor disallows it. + return descriptor.allowAllOf ? { allOf } : { anyOf: allOf }; } // Handle ZodTuple if (schema instanceof z.ZodTuple) { - // Tuples in JSON Schema can be represented as arrays with prefixItems - // For simplicity, we'll use an array type const items = schema.def.items.map(item => convert(item, context)); const rest = schema.def.rest ? convert(schema.def.rest, context) : undefined; + + // Object-numeric-keys encoding: per-position type preserved as object + // properties. Variadic rests can't be represented this way — fall back to + // a homogeneous array of (items ∪ rest) for that rare case. + if (descriptor.tupleEncoding === 'object-numeric-keys' && !rest) { + const properties: Record = {}; + const required: string[] = []; + for (let i = 0; i < items.length; i++) { + const k = String(i); + properties[k] = isOptional(schema.def.items[i]) ? makeNullable(items[i]!, descriptor) : items[i]!; + required.push(k); + } + return { + type: 'object', + properties, + required, + additionalProperties: false, + }; + } + const result: JSONSchema = { type: 'array' }; - if (context.strict && rest) { + if (descriptor.tupleEncoding === 'object-numeric-keys' && rest) { + // Strict + rest fallback: every position fits one of the declared + // types (positional info is lost — there's no way to express a + // mixed-prefix-plus-rest array in strict-object form). items.push(rest); } // If all items are the same type, simplify to a single items schema if (items.length > 0 && items.every((item) => JSON.stringify(item) === JSON.stringify(items[0]))) { result.items = items[0]; + } else if (descriptor.tupleEncoding === 'prefix-items') { + result.prefixItems = items; } else { - if (context.strict) { - result.items = { anyOf: items }; - } else { - result.prefixItems = items; - } + // items-union or strict-mode-with-rest fallback: collapse to anyOf + result.items = { anyOf: items }; } - if (!context.strict && rest) { + if (descriptor.tupleEncoding === 'prefix-items' && rest) { result.additionalItems = rest; } if (!rest) { @@ -643,12 +1158,14 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC result.maxItems = items.length; } - const { minimum, maximum } = schema._zod.bag; - if (typeof minimum === 'number') { - result.minItems = minimum; - } - if (typeof maximum === 'number') { - result.maxItems = maximum; + if (descriptor.allowMinMaxItems) { + const { minimum, maximum } = schema._zod.bag; + if (typeof minimum === 'number') { + result.minItems = minimum; + } + if (typeof maximum === 'number') { + result.maxItems = maximum; + } } return result; @@ -661,10 +1178,10 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC .filter(([k, _]) => numericValues.indexOf(+k) === -1) .map(([_, v]) => v); return { - type: values.every((v) => typeof v === 'number') - ? 'number' - : values.every((v) => typeof v === 'string') - ? 'string' + type: values.every((v) => typeof v === 'number') + ? 'number' + : values.every((v) => typeof v === 'string') + ? 'string' : undefined, enum: values, }; @@ -674,7 +1191,7 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC if (schema instanceof z.ZodLiteral) { const values = Array.from(schema.values).filter(v => v !== undefined && typeof v !== 'function' && typeof v !== 'symbol' && typeof v !== 'bigint'); const types = Array.from(new Set(values.map(v => v === null ? 'null' : typeof v) as ('string' | 'number' | 'boolean' | 'null')[])); - + return { ...(types.length === 1 ? { type: types[0] } : {}), ...(values.length === 1 ? { const: values[0] } : { enum: values }), @@ -689,15 +1206,12 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC } // Handle ZodLazy - don't unwrap here, let convert() handle caching - // Just return empty schema since ZodLazy should be handled at convert() level if (schema instanceof z.ZodLazy) { - // This shouldn't be reached because convert() handles ZodLazy before calling convertSchema throw new Error('ZodLazy should be handled in convert(), not convertSchema()'); } // Handle ZodCodec if (schema instanceof z.ZodCodec) { - // For codecs, use the input schema for JSON Schema generation return convert(schema.def.in, context); } @@ -713,37 +1227,36 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag; - if (context.strict) { - const strictFormats = ['date-time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid']; - if (typeof format === 'string' && strictFormats.includes(format)) { + // Format: emit only when allowed by the descriptor. + if (typeof format === 'string') { + const formats = descriptor.supportedStringFormats; + if (formats === 'all' || formats.has(format)) { result.format = format; } + } - if (patterns && patterns.size > 0) { - result.pattern = Array.from(patterns)[0].source; - } - } else { + if (descriptor.allowMinMaxLength) { if (typeof minimum === 'number') { result.minLength = minimum; } if (typeof maximum === 'number') { result.maxLength = maximum; } - if (typeof format === 'string') { - result.format = format; - } - if (typeof contentEncoding === 'string') { - result.contentEncoding = contentEncoding; - } - if (patterns) { - if (patterns.size === 1) { - result.pattern = Array.from(patterns)[0].source; - } else { - result.allOf = Array.from(patterns).map((regex) => ({ - type: 'string', - pattern: regex.source, - })); - } + } + + if (typeof contentEncoding === 'string' && descriptor.allowMinMaxLength) { + // contentEncoding piggybacks on the lenient gate (it's not a strict-mode-safe field) + result.contentEncoding = contentEncoding; + } + + if (patterns && descriptor.allowPattern) { + if (patterns.size === 1 || !descriptor.allowMultiplePatterns) { + result.pattern = Array.from(patterns)[0].source; + } else { + result.allOf = Array.from(patterns).map((regex) => ({ + type: 'string', + pattern: regex.source, + })); } } @@ -753,23 +1266,25 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC if (schema instanceof z.ZodNumber) { const result: JSONSchema = { type: 'number' }; - // Add number constraints if present const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag; if (typeof format === 'string' && format.includes("int")) { result.type = 'integer'; } - if (typeof exclusiveMinimum === 'number') { - result.exclusiveMinimum = exclusiveMinimum; - } else if (typeof minimum === 'number') { - result.minimum = minimum; - } - if (typeof exclusiveMaximum === 'number') { - result.exclusiveMaximum = exclusiveMaximum; - } else if (typeof maximum === 'number') { - result.maximum = maximum; - } - if (typeof multipleOf === 'number') { - result.multipleOf = multipleOf; + + if (descriptor.allowMinimumMaximum) { + if (typeof exclusiveMinimum === 'number') { + result.exclusiveMinimum = exclusiveMinimum; + } else if (typeof minimum === 'number') { + result.minimum = minimum; + } + if (typeof exclusiveMaximum === 'number') { + result.exclusiveMaximum = exclusiveMaximum; + } else if (typeof maximum === 'number') { + result.maximum = maximum; + } + if (typeof multipleOf === 'number') { + result.multipleOf = multipleOf; + } } return result; @@ -784,35 +1299,35 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC } if (schema instanceof z.ZodDate || schema instanceof z.ZodISODateTime) { - return { type: 'string', format: 'date-time' }; + return stringWithFormat('date-time', descriptor); } if (schema instanceof z.ZodISODate) { - return { type: 'string', format: 'date' }; + return stringWithFormat('date', descriptor); } if (schema instanceof z.ZodISOTime) { - return { type: 'string', format: 'time' }; + return stringWithFormat('time', descriptor); } if (schema instanceof z.ZodISODuration) { - return { type: 'string', format: 'duration' }; + return stringWithFormat('duration', descriptor); } if (schema instanceof z.ZodEmail) { - return { type: 'string', format: 'email' }; + return stringWithFormat('email', descriptor); } if (schema instanceof z.ZodIPv4) { - return { type: 'string', format: 'ipv4' }; + return stringWithFormat('ipv4', descriptor); } if (schema instanceof z.ZodIPv6) { - return { type: 'string', format: 'ipv6' }; + return stringWithFormat('ipv6', descriptor); } if (schema instanceof z.ZodUUID) { - return { type: 'string', format: 'uuid' }; + return stringWithFormat('uuid', descriptor); } if (schema instanceof z.ZodNull) { @@ -840,7 +1355,7 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC // `$ref: '#/$defs/Any'` references don't infinite-loop during // construction. context.definitionSchemas.set(id, {}); - context.definitionSchemas.set(id, buildAnyValueSchema(context.strict)); + context.definitionSchemas.set(id, buildAnyValueSchema(descriptor)); } return { $ref: `#/$defs/${id}` }; } @@ -857,16 +1372,13 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC /** * Builds the body of the shared `$defs/Any` schema — a self-recursive - * `anyOf` covering every JSON value in a strict-mode-compatible way. + * `anyOf` covering every JSON value. * - * In strict mode, open-ended objects (`additionalProperties` anything - * other than `false`) are rejected by OpenAI's structured-output - * validator, so "arbitrary object" is represented as an array of - * `{key, value}` pairs — the same workaround already used for - * `ZodRecord` in strict mode. Non-strict mode gets the more intuitive - * open-record shape. + * Open-record dialects use `additionalProperties: ` for the object + * branch. Strict dialects (which forbid open records) use the array-of-pairs + * workaround instead — same shape we use for `ZodRecord` in strict mode. */ -function buildAnyValueSchema(strict: boolean): JSONSchema { +function buildAnyValueSchema(descriptor: FormatDescriptor): JSONSchema { const selfRef: JSONSchema = { $ref: '#/$defs/Any' }; const branches: JSONSchema[] = [ { type: 'string' }, @@ -875,7 +1387,7 @@ function buildAnyValueSchema(strict: boolean): JSONSchema { { type: 'null' }, { type: 'array', items: selfRef }, ]; - if (strict) { + if (descriptor.anyEncoding === 'recursive-strict') { branches.push({ type: 'array', items: { @@ -898,11 +1410,21 @@ function buildAnyValueSchema(strict: boolean): JSONSchema { } /** - * Makes a JSON Schema nullable by adding null to the type + * Makes a JSON Schema nullable. + * + * For descriptors where optional becomes nullable on the wire (OpenAI strict), + * this rewrites the schema to either include `null` in `type` or wrap in + * `anyOf: [..., {type: 'null'}]`. For other descriptors this is a no-op (the + * caller handles optional via the `required[]` list instead). */ -function makeNullable(schema: JSONSchema): JSONSchema { +function makeNullable(schema: JSONSchema, descriptor: FormatDescriptor): JSONSchema { + // Lenient/standard dialects don't rewrite — optional is expressed via + // required[] omission, not via type-union with null. + if (!descriptor.optionalAsNullable) { + return schema; + } + if (schema.$ref) { - // If it's a reference, wrap in anyOf with null return { anyOf: [ { $ref: schema.$ref }, @@ -912,8 +1434,6 @@ function makeNullable(schema: JSONSchema): JSONSchema { } if (schema.type) { - // Check if schema has type-specific properties that require anyOf structure - // Arrays have 'items', objects have 'properties', etc. const hasTypeSpecificProps = schema.items !== undefined || schema.properties !== undefined || @@ -924,9 +1444,7 @@ function makeNullable(schema: JSONSchema): JSONSchema { schema.minProperties !== undefined || schema.maxProperties !== undefined; - // If there are type-specific properties, must use anyOf to separate type schemas if (hasTypeSpecificProps) { - // Extract type-specific properties based on the type const { type, description, ...rest } = schema; const baseType = Array.isArray(type) ? type[0] : type; @@ -939,7 +1457,6 @@ function makeNullable(schema: JSONSchema): JSONSchema { }; } - // For simple types without type-specific properties, can use array notation const types = Array.isArray(schema.type) ? schema.type : [schema.type]; if (!types.includes('null')) { return { @@ -951,7 +1468,6 @@ function makeNullable(schema: JSONSchema): JSONSchema { } if (schema.anyOf) { - // Already has anyOf, add null option if not present const hasNull = schema.anyOf.some((s) => s.type === 'null'); if (!hasNull) { return { @@ -962,7 +1478,6 @@ function makeNullable(schema: JSONSchema): JSONSchema { return schema; } - // For complex schemas without type, wrap in anyOf return { anyOf: [ schema, @@ -973,4 +1488,323 @@ function makeNullable(schema: JSONSchema): JSONSchema { function isOptional(schema: z.ZodType | z.core.$ZodType): boolean { return schema._zod.optin !== undefined; -} \ No newline at end of file +} + +/** + * Emit a `{ type: 'string', format: ... }` JSON Schema, dropping the format + * when the descriptor's whitelist excludes it. The string type is always + * preserved; only the format hint is gated. + */ +function stringWithFormat(format: string, descriptor: FormatDescriptor): JSONSchema { + const formats = descriptor.supportedStringFormats; + if (formats === 'all' || formats.has(format)) { + return { type: 'string', format }; + } + return { type: 'string' }; +} + +// ============================================================================ +// Schema feature analysis + per-request strict allocation +// ============================================================================ + +/** + * Structural feature counts for a Zod schema, used by the SchemaBudget to + * decide whether an item fits a descriptor's per-request budget and whether + * the descriptor can express it at all. + */ +export interface SchemaFeatures { + /** True if the schema (or any subschema) uses `z.lazy` / self-recursion. */ + hasRecursion: boolean; + /** Count of `z.optional()` leaves anywhere in the schema. */ + optionalParameterCount: number; + /** + * Count of union-shaped nodes — `z.union`, `z.discriminatedUnion`, plus + * `z.nullable` (which on the wire under OpenAI strict becomes a type + * array `[T, "null"]`, also counts toward Anthropic's union-type budget). + */ + unionTypeCount: number; + /** Count of `z.record` nodes. */ + recordCount: number; + /** Count of `z.tuple` nodes. */ + tupleCount: number; +} + +const ZERO_FEATURES: SchemaFeatures = Object.freeze({ + hasRecursion: false, + optionalParameterCount: 0, + unionTypeCount: 0, + recordCount: 0, + tupleCount: 0, +}); + +const featuresCache = new WeakMap(); + +/** + * Walk a Zod schema once and count its structural features. Result is + * cached per schema in a WeakMap — second-and-subsequent calls are O(1) and + * the entry is GC'd with the schema (same OOM-safe pattern as `strictify`). + */ +export function analyzeSchema(schema: z.ZodType | z.core.$ZodType): SchemaFeatures { + const cached = featuresCache.get(schema); + if (cached) return cached; + + const visiting = new Set(); + const features: SchemaFeatures = { ...ZERO_FEATURES }; + + function walk(s: z.ZodType | z.core.$ZodType | undefined | null): void { + if (!s) return; + if (visiting.has(s)) return; + visiting.add(s); + try { + // Recursion is detected via z.lazy: peek at the inner schema (but only + // once — the visiting set bounds the walk). + if (s instanceof z.ZodLazy) { + features.hasRecursion = true; + try { + walk(s.def.getter()); + } catch { /* lazy getter may throw at analyze time; ignore */ } + return; + } + + if (s instanceof z.ZodOptional) { + features.optionalParameterCount += 1; + walk(s.unwrap()); + return; + } + + if (s instanceof z.ZodNullable) { + features.unionTypeCount += 1; // T|null counts as a union on the wire + walk(s.unwrap()); + return; + } + + if (s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion) { + features.unionTypeCount += 1; + for (const opt of (s as z.ZodUnion).options) walk(opt); + return; + } + + if (s instanceof z.ZodIntersection) { + walk(s.def.left); + walk(s.def.right); + return; + } + + if (s instanceof z.ZodObject) { + for (const key in s.shape) walk(s.shape[key]); + if (s.def.catchall) walk(s.def.catchall); + return; + } + + if (s instanceof z.ZodArray) { + walk(s.element); + return; + } + + if (s instanceof z.ZodRecord) { + features.recordCount += 1; + if (s.keyType) walk(s.keyType); + walk(s.valueType); + return; + } + + if (s instanceof z.ZodTuple) { + features.tupleCount += 1; + for (const item of s.def.items) walk(item); + if (s.def.rest) walk(s.def.rest); + return; + } + + if (s instanceof z.ZodDefault) { + walk(s.def.innerType); + return; + } + + if (s instanceof z.ZodCodec || s instanceof z.ZodPipe) { + walk(s.def.in); + walk(s.def.out); + return; + } + + // Primitives and unknown leaves contribute nothing. + } finally { + visiting.delete(s); + } + } + + walk(schema); + Object.freeze(features); + featuresCache.set(schema, features); + return features; +} + +/** + * Decide whether a single schema can be expressed under the given descriptor + * regardless of remaining slot budget. + * + * Returns `false` only when the descriptor lacks a feature the schema + * fundamentally needs — today that means recursion under a descriptor with + * `supportsRecursion: false` (Anthropic strict). The schema can still go + * through; it just falls back to LENIENT for that one item. + */ +export function isStrictFeasible( + schema: z.ZodType | z.core.$ZodType, + descriptor: FormatDescriptor, +): boolean { + if (!descriptor.strict) return true; // LENIENT accepts anything + const features = analyzeSchema(schema); + if (features.hasRecursion && !descriptor.supportsRecursion) return false; + return true; +} + +/** + * Per-request strict-mode allocator. + * + * Constructed once per outgoing request with the chosen model's strictest + * descriptor. Tracks remaining slots (strict tools, optional parameters, + * union types) against the descriptor's documented per-request limits and + * decides per-tool / per-output whether to emit strict or fall back to + * LENIENT. + * + * Selection guarantees that any tool with `strict: true` (hard requirement) + * has a model that supports the family — `allocate` therefore always grants + * strict for `requested === true` (subject only to feasibility, not budget; + * if `true` items overflow the budget the API call will fail, which is the + * correct behavior for hard requirements). + * + * Numeric `strict: ` items are allocated greedily after `true` + * items have consumed their share of the budget. Caller is expected to + * pre-sort by descending priority before iterating. + */ +export class SchemaBudget { + private readonly descriptor: FormatDescriptor; + private remainingTools: number; + private remainingOptionalParams: number; + private remainingUnionTypes: number; + + constructor(descriptor: FormatDescriptor) { + this.descriptor = descriptor; + this.remainingTools = descriptor.maxStrictTools ?? Infinity; + this.remainingOptionalParams = descriptor.maxStrictOptionalParams ?? Infinity; + this.remainingUnionTypes = descriptor.maxStrictUnionTypes ?? Infinity; + } + + /** + * Decide the effective descriptor for a tool's parameter schema given the + * dev's strict request, the schema's features, and the remaining budget. + * + * Returns `LENIENT` when: + * - `requested === false` + * - the schema uses a feature the descriptor can't represent + * - the descriptor has slot limits and the budget is exhausted (only + * applies to `requested` being a positive number; `true` always wins + * subject to feasibility) + * + * Returns the strict descriptor (and decrements the budget) otherwise. + */ + allocateTool( + schema: z.ZodType | z.core.$ZodType, + requested: boolean | number | undefined, + ): FormatDescriptor { + return this.allocate(schema, requested, /* isTool */ true); + } + + /** + * Same as `allocateTool` but for the request's structured-output schema. + * The output schema doesn't consume a tool slot but it does count toward + * the optional-parameter and union-type budgets — descriptors document + * those limits as "across all strict schemas in one request". + */ + allocateOutput( + schema: z.ZodType | z.core.$ZodType, + requested: boolean | number | undefined, + ): FormatDescriptor { + return this.allocate(schema, requested, /* isTool */ false); + } + + /** Snapshot of remaining budget. Useful for telemetry / debug logs. */ + remaining(): { strictTools: number; optionalParams: number; unionTypes: number } { + return { + strictTools: this.remainingTools, + optionalParams: this.remainingOptionalParams, + unionTypes: this.remainingUnionTypes, + }; + } + + private allocate( + schema: z.ZodType | z.core.$ZodType, + requested: boolean | number | undefined, + isTool: boolean, + ): FormatDescriptor { + // strict: false → always lenient, no budget movement. + if (requested === false) return LENIENT; + // Descriptor itself is lenient → nothing to allocate. + if (!this.descriptor.strict) return LENIENT; + // Schema feature unsupported by descriptor → silent fallback to lenient. + if (!isStrictFeasible(schema, this.descriptor)) return LENIENT; + + const features = analyzeSchema(schema); + const isHardRequired = requested === true; + const isPreferredNumeric = typeof requested === 'number' && requested > 0; + + if (!isHardRequired && !isPreferredNumeric) { + // requested is undefined or 0 — treat as no preference; default to + // lenient so callers explicitly opt into strict. + return LENIENT; + } + + // Soft-priority items must fit the budget. Hard-required items skip + // budget checks because selection already promised the model can take + // them — if there are too many, that's a request-construction bug we + // surface via API error rather than degrading silently. + if (isPreferredNumeric) { + if (isTool && this.remainingTools <= 0) return LENIENT; + if (this.remainingOptionalParams - features.optionalParameterCount < 0) return LENIENT; + if (this.remainingUnionTypes - features.unionTypeCount < 0) return LENIENT; + } + + // Allocation succeeded — decrement. + if (isTool) this.remainingTools -= 1; + this.remainingOptionalParams -= features.optionalParameterCount; + this.remainingUnionTypes -= features.unionTypeCount; + + return this.descriptor; + } +} + +/** + * Compare two descriptors and return the one with the tighter per-request + * budget. Used by providers to pick a single descriptor for a SchemaBudget + * shared between `convertTools` and `convertResponseFormat` — Anthropic's + * documented limits apply across the whole request, so the strictest + * descriptor wins. + */ +export function strictestOf(a: FormatDescriptor, b: FormatDescriptor): FormatDescriptor { + // LENIENT is the loosest — pick the strict side if either is strict. + if (a.strict && !b.strict) return a; + if (b.strict && !a.strict) return b; + if (!a.strict && !b.strict) return a; + // Both strict — pick the one with the smallest documented limits. + const score = (d: FormatDescriptor): number => + (d.maxStrictTools ?? Infinity) + + (d.maxStrictOptionalParams ?? Infinity) + + (d.maxStrictUnionTypes ?? Infinity); + return score(a) <= score(b) ? a : b; +} + +/** + * Convert a `boolean | number | undefined` strict request into a numeric + * priority for sorting. Used by providers to allocate strict slots in + * descending priority order. + * + * - `true` → `+Infinity` (hard requirement; always first in line) + * - `false` → `-Infinity` (always lenient; never wants strict) + * - `number > 0` → the number itself + * - `number <= 0` or `undefined` → `0` (no preference) + */ +export function strictPriority(requested: boolean | number | undefined): number { + if (requested === true) return Infinity; + if (requested === false) return -Infinity; + if (typeof requested === 'number') return requested > 0 ? requested : 0; + return 0; +} diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 8404eb4d..612fd638 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -1,7 +1,7 @@ import Handlebars from 'handlebars'; import { ZodType } from 'zod'; import { Fn, resolveFn } from './common'; -import { strictify } from './schema'; +import { FormatDescriptor, getDescriptorById, strictify } from './schema'; import { Component, ComponentCompatible, Context, OptionalParams, ToolDefinition, Tuple } from './types'; /** @@ -37,8 +37,25 @@ export interface ToolInput< input?: Fn, [Context]>; /** Zod schema defining the tool's input parameters */ schema: Fn | undefined, [Context]>; - /** Whether to require AI to strictly follow the schema. True bu default. */ - strict?: boolean; + /** + * Strict-mode policy for this tool's schema. Tri-state, with `1` (best-effort + * preference) as the default when omitted: + * + * - `true` — REQUIRE strict. Selection filters out models without the + * matching strict-tool family; if no model qualifies the request fails. + * - `false` — FORCE lenient. Schema is emitted as standard JSON Schema + * regardless of model capability, no `strict: true` flag on the wire. + * - `number > 0` (default `1`) — PREFER strict; tolerate fallback. The + * number is a priority — higher means more wanted. Selection biases + * toward strict-capable models (optional capability). At request build + * time the `SchemaBudget` allocates strict slots in priority order; + * tools that don't fit fall back to lenient silently. + * + * Note: the legacy default of `true` was changed to `1` in v2 to keep + * "it just works" against unknown/unannotated models. Set `strict: true` + * explicitly when strict is non-negotiable. + */ + strict?: boolean | number; /** References to other components (tools, prompts, agents) that this tool utilizes */ refs?: TRefs; /** The function that implements the tool's behavior */ @@ -165,7 +182,9 @@ export class Tool< constructor( public input: ToolInput, private instructions = input.instructions ? Tool.compileInstructions(input.instructions, !!input.input) : undefined, - private schema = resolveFn(input.schema, (s) => s && input.strict !== false ? strictify(s) as ZodType : s), + // Schema stays raw. The provider applies the matching strictify lazily + // once it knows the chosen model's strict-tools format (descriptor). + private schema = resolveFn(input.schema), private translate = resolveFn(input.input), private descriptionFn = resolveFn(input.descriptionFn), private instructionsFn = resolveFn(input.instructionsFn, (r) => r ? Tool.compileInstructions(r, !!input.input) : undefined), @@ -199,13 +218,25 @@ export class Tool< * @returns The parsed and validated input parameters. * @throws Error if schema is not available or parsing/validation fails. */ - async parse(ctx: Context, args: string, schema?: ZodType): Promise { + async parse( + ctx: Context, + args: string, + schema?: ZodType, + descriptor?: FormatDescriptor | string, + ): Promise { let resolvedSchema = schema || await this.schema(ctx); if (!resolvedSchema) { throw new Error(`Not able to build a schema to parse arguments for ${this.input.name}`); } + // Apply the strictify rewrite that matches the provider's chosen wire + // dialect. The cache makes repeated calls O(1). + if (descriptor) { + const fd = typeof descriptor === 'string' ? getDescriptorById(descriptor) : descriptor; + resolvedSchema = strictify(resolvedSchema, fd); + } + const parsed = await resolvedSchema.parseAsync(JSON.parse(args)); // Run post-validation hook if provided @@ -245,7 +276,9 @@ export class Tool< // Get dynamic description if function is provided const description = await this.descriptionFn(ctx) || this.input.description; - const strict = this.input.strict ?? true; + // Default to numeric priority 1 (best-effort preference) instead of + // boolean true. See ToolInput.strict JSDoc for the tri-state semantics. + const strict: boolean | number = this.input.strict ?? 1; return [ instructions, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ae55dfa7..794b2f8a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -452,10 +452,26 @@ export interface ToolDefinition name: string; /** Description of what the tool does */ description?: string; - /** Zod schema defining the tool's input parameters */ + /** Zod schema defining the tool's input parameters (raw — strict rewrites are applied lazily by the provider) */ parameters: z.ZodType; - /** Whether to require AI to strictly follow the schema. True by default. */ - strict?: boolean; + /** + * Strict-mode policy. See `ToolInput.strict` JSDoc for full semantics. + * + * - `true` — require strict (selection filter) + * - `false` — force lenient + * - `number > 0` (default `1`) — prefer strict, accept fallback; the + * number is the priority for SchemaBudget allocation when there are + * more strict-requesting tools than the descriptor's per-request slot + * budget allows. + */ + strict?: boolean | number; + /** + * The FormatDescriptor id the provider chose for this tool's wire shape, set + * during request building. Used by `Tool.parse` to apply the matching + * strictify before validating arguments. Absent until the request reaches a + * provider; absent altogether when the request runs against a non-strict model. + */ + descriptor?: string; } /** @@ -496,7 +512,21 @@ export type ToolChoice = export type ResponseFormat = | 'text' | 'json' - | { type: z.ZodType, strict: boolean }; + | { + type: z.ZodType, + /** + * Strict-mode policy. See `PromptInput.strict` for tri-state semantics. + * `true` requires; `false` forces lenient; positive number prefers + * strict with that priority for SchemaBudget allocation. + */ + strict: boolean | number, + /** + * The FormatDescriptor id the provider chose for the wire shape, set + * during request building. Used by the Prompt validator to apply the + * matching strictify before parsing model output. + */ + descriptor?: string, + }; /** * Statistics about usage for an AI request. diff --git a/packages/docs/.gitignore b/packages/docs/.gitignore new file mode 100644 index 00000000..6dc972ee --- /dev/null +++ b/packages/docs/.gitignore @@ -0,0 +1 @@ +.vitepress/cache \ No newline at end of file diff --git a/packages/docs/.vitepress/config.ts b/packages/docs/.vitepress/config.ts index 47c0598c..b0c39b26 100644 --- a/packages/docs/.vitepress/config.ts +++ b/packages/docs/.vitepress/config.ts @@ -26,6 +26,7 @@ export default defineConfig({ { text: 'Guide', link: '/getting-started/installation' }, { text: 'Components', link: '/components/tools' }, { text: 'Providers', link: '/providers/openai' }, + { text: 'Gin', link: '/gin/' }, { text: 'API Reference', link: '/reference/core/types' }, { text: 'Examples', link: '/examples/basic-chat' }, ], @@ -72,6 +73,7 @@ export default defineConfig({ { text: 'Streaming', link: '/guides/streaming' }, { text: 'Tool Calling', link: '/guides/tool-calling' }, { text: 'Structured Output', link: '/guides/structured-output' }, + { text: 'Strict Mode', link: '/guides/strict-mode' }, { text: 'Image Generation', link: '/guides/image-generation' }, { text: 'Image Analysis (Vision)', link: '/guides/vision' }, { text: 'Speech Synthesis', link: '/guides/speech' }, @@ -86,6 +88,19 @@ export default defineConfig({ ], }, ], + '/gin/': [ + { + text: 'gin — LLM-authorable runtime', + items: [ + { text: 'Overview', link: '/gin/' }, + { text: 'Type System', link: '/gin/types' }, + { text: 'Expressions', link: '/gin/expressions' }, + { text: 'Registry', link: '/gin/registry' }, + { text: 'Built-in Types', link: '/gin/built-ins' }, + { text: 'Diagnostics', link: '/gin/diagnostics' }, + ], + }, + ], '/providers/': [ { text: 'Providers', @@ -146,6 +161,7 @@ export default defineConfig({ { text: 'Budget Control', link: '/examples/budget-control' }, { text: 'Multi-Provider Fallback', link: '/examples/multi-provider' }, { text: 'Cletus — Full CLI Agent', link: '/examples/cletus' }, + { text: 'Ginny — Typed Programs', link: '/examples/ginny' }, ], }, ], diff --git a/packages/docs/concepts/models.md b/packages/docs/concepts/models.md index 0b0b04e4..0da3bdf2 100644 --- a/packages/docs/concepts/models.md +++ b/packages/docs/concepts/models.md @@ -185,9 +185,28 @@ await ai.models.refresh(); Use `@aeye/models` for static model definitions: ```typescript -import { models } from '@aeye/models'; +import { models, strictSupport } from '@aeye/models'; const ai = AI.with() .providers({ openai }) - .create({ models }); + .create({ + models, + // Curated strict-mode dialect declarations. Opts strict-capable model + // families (gpt-4o+, claude 4.5+, gemini 2.0+) into the `'toolsStrict'` + // capability and pins their JSON-Schema dialect. Without this, every + // model defaults to lenient even when the underlying API supports strict. + modelOverrides: [...strictSupport], + }); +``` + +`strictSupport` is a `ModelOverride[]` you can splat alongside your own +overrides. It only adds `strictFormat` (auto-derives the `'toolsStrict'` +capability) — it doesn't touch pricing, tiers, or other fields, so it's +safe to combine with arbitrary overrides: + +```typescript +modelOverrides: [ + ...strictSupport, + { modelPattern: /gpt-4/, overrides: { tier: 'flagship' } }, +], ``` diff --git a/packages/docs/examples/ginny.md b/packages/docs/examples/ginny.md new file mode 100644 index 00000000..85e5ecc9 --- /dev/null +++ b/packages/docs/examples/ginny.md @@ -0,0 +1,126 @@ +# ginny — Natural-Language → Typed Programs + +Ginny is a CLI agent built on `@aeye` that turns natural-language requests into executable [gin](../gin/) programs. Every type, function, and var the LLM creates is persisted as JSON in your project, building a typed catalog that grows with your codebase. + +```bash +npm install -g @aeye/ginny +cd my-project +ginny # opens an interactive REPL +ginny "add 2 and 3" # one-shot +``` + +[View source on GitHub](https://github.com/ClickerMonkey/aeye/tree/main/packages/ginny) + +## What it demonstrates + +| Feature | How ginny uses it | +|---|---| +| **Multi-provider** | OpenAI + OpenRouter + AWS Bedrock; credentials probed via standard SDK chain (env, `aws sso login`, IAM role, `~/.aws/credentials`). | +| **Sub-agents** | Five specialized agents (programmer / architect / designer / dba / researcher), each with their own toolset. The programmer recursively spins up new programmers when designing fn bodies. | +| **Adaptive tool selection** | Catalog searches use embedding-based filtering when corpus size exceeds a threshold; small catalogs return everything. | +| **Structured output** | Sub-agents produce typed gin `ExprDef` / `TypeDef` JSON via Zod schemas — the LLM literally cannot return invalid expressions. | +| **Strict mode** | Wires in [`strictSupport`](../guides/strict-mode.md) from `@aeye/models` so gpt-4o / claude 4.5+ / gemini 2.0+ get grammar-constrained tool inputs. | +| **Prompt files** | Loads `cletus.md` / `agents.md` / `claude.md` from the working directory as system context. | +| **Context management** | Per-sub-agent context with run-state isolation; usage and cost accumulate across the session. | +| **Hooks** | Provider-level request/response logging with payload-size tracking; AI-level model-selection logging. | + +## Architecture + +A small council of sub-agents, each with one job: + +``` + ┌─────────────┐ + user request ──▶ │ programmer │ + └──────┬──────┘ + ┌────────────────┬──────┴──────┬────────────────┐ + ▼ ▼ ▼ ▼ + ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ architect │ │ designer │ │ dba │ │ researcher │ + │ (types) │ │ (fns) │ │ (vars) │ │ (web search │ + │ │ │ │ │ │ │ + pages) │ + └─────────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘ + │ + ▼ (recursive spin-up) + programmer +``` + +- **programmer** — writes a draft gin program, calls `test()` against sample args, calls `finish()` when a test passes. Uses `find_or_create_*` tools to pull in catalog entries; uses `edit_type` / `edit_fn` for backwards-compatible edits. +- **architect / designer / dba** — search-and-create patterns over `./types/*.json`, `./fns/*.json`, `./vars/*.json`. +- **researcher** — wraps `web_search` (Tavily) + `web_get_page`, returns `{ answer, sources }`. + +Each sub-agent is an `@aeye` Prompt with its own toolset; when the programmer needs a new function, it asks the designer, which spawns *another* programmer to author the body. Recursion is a first-class workflow primitive. + +## Persistence + +Catalog entries are one JSON file per name, in three CWD-relative directories: + +``` +./types/Task.json # the Task type +./fns/factorial.json # the factorial function +./vars/apiBaseUrl.json # a persistent var (type + value + docs) +``` + +Filenames are identity. You can hand-edit any file between sessions; ginny picks up changes on the next run. Drop a new file in by hand — discovered on next search. + +## Key patterns from the source + +### AI wiring (`packages/ginny/src/ai.ts`) + +Multi-provider setup with `strictSupport` wired in so strict-capable model families auto-engage strict mode: + +```typescript +import { models, strictSupport } from '@aeye/models'; + +const ai = AI.with() + .providers(enabledProviders) + .create({ + defaultContext: { /* ... */ }, + defaultMetadata: { providers: providersMeta }, + models, + modelOverrides: [...strictSupport], + }) + .withHooks({ + onModelSelected: async (ctx, request, selected) => { + logger.log(`model selected: ${selected.model.id}`); + return selected; + }, + }); +``` + +### Per-sub-agent model override (`packages/ginny/src/registry.ts`) + +Each sub-agent reads its model from a dedicated env var (`GIN_PROGRAMMER_MODEL`, `GIN_DESIGNER_MODEL`, ...) and passes it through metadata. Sub-agents can run on different tiers: + +```bash +GIN_PROGRAMMER_MODEL=gpt-5 # heavy lifting +GIN_DBA_MODEL=gpt-4o-mini # cheap, just file IO +GIN_RESEARCHER_MODEL=... +``` + +### Strict tool schemas (`packages/gin/src/schemas.ts`) + +The build / test / finish loop schemas use `strict: true` because malformed gin expressions are unrecoverable — the agent must produce valid `ExprDef` JSON or fail loudly. With `strictSupport` wired in, selection only considers strict-capable models for these prompts. + +## When to look at ginny + +- **You're building a sub-agent system.** Ginny's recursive spin-up pattern (programmer asks designer asks programmer) is hard to find documented elsewhere. +- **You want a CWD-relative typed catalog.** The types / fns / vars-as-files pattern is reusable; ginny's catalog code is small. +- **You're integrating gin into your own tool.** Ginny is the reference embedding — everything it does is a thin layer of tool definitions over [`@aeye/gin`](../gin/). +- **You want to see strict mode + multi-provider in production.** The `ai.ts` file is ~330 lines covering provider probing, retry/hook wiring, payload-size logging, and strict-support integration. + +## Running from source + +```bash +git clone https://github.com/ClickerMonkey/aeye.git +cd aeye +npm install +cd packages/ginny +npm run start # dev (tsx --conditions=source) +npm run build # bundled dist/index.js with shebang +``` + +## Related + +- [`@aeye/gin`](../gin/) — the typed-program runtime ginny is built on. +- [Cletus example](./cletus.md) — another full-CLI agent built on `@aeye`, with adaptive tool selection. +- [Strict Mode guide](../guides/strict-mode.md) — what `strictSupport` engages. diff --git a/packages/docs/gin/built-ins.md b/packages/docs/gin/built-ins.md new file mode 100644 index 00000000..44dc36b0 --- /dev/null +++ b/packages/docs/gin/built-ins.md @@ -0,0 +1,298 @@ +# Built-in Types + +`createRegistry()` ships with the catalog below — the surface every gin program starts with. Each type's section is the same `toCodeDefinition` output an LLM sees in its prompt, so what you read here is what the LLM reads when authoring programs. + +These types can all be **extended** (real subtyping, see [Extensions in Type System](./types.md#extensions)) or **augmented** (gap-filling additions of props / get / call / init, see [Augmentations](./types.md#augmentations)). Augmentations of built-ins flow through every consumer — path-walks dispatch against augmented props, static analysis sees them, code rendering shows them. + +## Foundational + +```text +type any { + toAny(): any + typeOf(): text + is(): bool + as(): optional + toText(): text + toBool(): bool + eq(other: any): bool + neq(other: any): bool +} + +type void { + toAny(): any + toText(): text + toBool(): bool +} + +type null { + toAny(): any + toText(): text + toBool(): bool +} +``` + +`any` is the universal escape hatch. `void` is the bottom for procedures that return nothing. `null` is the explicit null marker (distinct from `optional`'s "no value"). + +## Primitives + +```text +type bool { + [key: num{whole=true, min=0}]: bool + toAny(): any + eq(other: bool): bool + neq(other: bool): bool + and(other: bool): bool + or(other: bool): bool + xor(other: bool): bool + not(): bool + toText(): text + toNum(): num +} + +type num { + [key: num{whole=true, min=0}]: num + toAny(): any + eq(other: num, epsilon?: num): bool + neq(other: num, epsilon?: num): bool + lt(other: num): bool + lte(other: num): bool + gt(other: num): bool + gte(other: num): bool + add(other: num): num + sub(other: num): num + mul(other: num): num + div(other: num): num + mod(other: num): num + pow(other: num): num + abs(): num + neg(): num + sign(): num + sqrt(): num + min(other: num): num + max(other: num): num + clamp(min: num, max: num): num + floor(): num + ceil(): num + round(): num + isZero(): bool + isPositive(): bool + isNegative(): bool + isInteger(): bool + isEven(): bool + isOdd(): bool + toText(precision?: num): text + toBool(): bool +} + +type text { + [key: num]: text{minLength=1, maxLength=1} + toAny(): any + length: num + eq(other: text): bool + neq(other: text): bool + contains(search: text): bool + startsWith(prefix: text): bool + endsWith(suffix: text): bool + trim(): text + trimStart(): text + trimEnd(): text + upper(): text + lower(): text + slice(start: num, end?: num): text + replace(search: text, replacement: text): text + split(separator: text): list + concat(other: text): text + repeat(count: num): text + indexOf(search: text, from?: num): num + lastIndexOf(search: text, from?: num): num + match(pattern: text): list + test(pattern: text): bool + isEmpty(): bool + isNotEmpty(): bool + toNum(): num + toBool(): bool +} +``` + +`bool` has `loopDynamic` get semantics — a `loop over: ` re-evaluates the bool each iteration (while-loop). `text[i]` returns a one-character text constrained by `minLength=1, maxLength=1`. + +## Collections + +```text +type list { + [key: num{whole=true, min=0}]: V + length: num + at(index: num): optional + push(value: V): void + pop(): optional + shift(): optional + unshift(value: V): void + insert(index: num, value: V): void + remove(index: num): V + clear(): void + slice(start?: num, end?: num): list + concat(other: list): list + reverse(): list + join(separator?: text): text + indexOf(value: V): num + contains(value: V): bool + unique(): list + duplicates(): list + map(fn: (value: V, index: num): R): list + filter(fn: (value: V, index: num): bool): list + find(fn: (value: V, index: num): bool): optional + reduce(fn: (acc: R, value: V, index: num): R, initial: R): R + some(fn: (value: V, index: num): bool): bool + every(fn: (value: V, index: num): bool): bool + sort(fn?: (a: V, b: V): num): list + isEmpty(): bool + isNotEmpty(): bool + first?: V + last?: V +} + +type map { + [key: K]: V + size: num + at(key: K): optional + has(key: K): bool + delete(key: K): bool + clear(): void + keys(): list + values(): list + isEmpty(): bool + isNotEmpty(): bool +} + +type tuple<...elements> { + [key: num]: + length: num + first: + last: + toList(): list +} + +type obj { + keys(): list + values(): list + entries(): list> + has(key: text): bool + eq(other: any): bool + neq(other: any): bool + toText(): text +} +``` + +Both `list` and `map` define `loop` get expressions, so they iterate via `{kind: 'loop', over: , body: ...}` directly — `key` binds the index/key, `value` binds the element. `obj` has dynamic prop access through `keys()` / `values()` / `entries()`. + +## Container modifiers + +```text +type optional { + value: T + has(): bool + or(fallback: T): T + map(fn: (value: T): R): optional +} + +type nullable { + value: T + isNull(): bool + or(fallback: T): T + map(fn: (value: T): R): nullable +} +``` + +`optional` is "T or absent" (used for "missing" — a method that might not return a value). `nullable` is "T or null" (used when null is a meaningful value). + +## Type-system constructors + +```text +type or<...variants> // union; props/get/call when ALL variants share them +type and<...parts> // intersection; props from ANY part +type not // any value EXCEPT one matching excluded +type literal // one specific constant value of T +type enum // named constants of value type V +type function // see "call" — args/returns/throws/generic +type interface // structural contract; props/get/call only +type typ // a value that IS a Type, constrained by T +type alias // bare-name reference / generic placeholder +``` + +These are the building blocks for parameterized signatures. `typ` is the type of "a type that satisfies T" — used to pass types as values into `fns.fetch({output: typ})`-style callsites. + +## Date & time + +```text +type date { + year, month, day, dayOfWeek, dayOfYear // num + eq, neq, before, after // (other: date) → bool + addDays/Months/Years, diffDays/Months/Years + toText(format?): text +} + +type timestamp { + year..millisecond // num + eq, before, after // (other: timestamp) → bool + addDuration, subDuration, diff + toDate(): date + toEpoch(): num + toText(format?): text +} + +type duration { + new(days?, hours?, minutes?, seconds?, ms?) + totalSeconds, totalMinutes, totalHours, totalDays + days, hours, minutes, seconds, ms + toText(format?): text +} +``` + +`duration` ships with `init`, so `new duration({hours: 2, minutes: 30})` runs the constructor. + +## Color + +```text +type color { + new(r, g, b, a?) + r, g, b, a, hue, saturation, lightness // num + eq, neq // (other: color) → bool + lighten, darken, saturate, desaturate, opacity, invert, mix, complement + toHex, toRgb, toHsl, toText // → text +} +``` + +Like `duration`, `color` ships with `init` — `new color({r: 255, g: 0, b: 0})` packs the channels. + +## Augmenting built-ins + +Built-ins aren't sealed. You can augment any of them to add app-specific helpers without subclassing: + +```typescript +r.augment('text', { + props: { + truncate: r.method( + r.obj({ max: { type: r.num({ whole: true, min: 1 }) } }), + r.text(), + 'text.truncate', + ), + }, +}); +r.setNative('text.truncate', (scope, reg) => { + const self = scope.get('this')!.raw as string; + const max = (scope.get('args')!.raw as { max: { raw: number } }).max.raw; + return val(reg.text(), self.length > max ? self.slice(0, max) + '…' : self); +}); + +// LLM-authored: +// "hello world".truncate(5) → "hello…" +``` + +The augmented method shows up in `text.toCodeDefinition()` next time it's rendered, which means it shows up in the LLM's prompt schema for free. No second prompt to reference, no hardcoded list to maintain. + +## Read next + +- [Type System](./types.md) — props / get / call / init, generics, extensions, augmentations. +- [Expressions](./expressions.md) — the 12 expression kinds. +- [Registry](./registry.md) — registration patterns and native bindings. +- [Diagnostics](./diagnostics.md) — formatting validation problems. diff --git a/packages/docs/gin/diagnostics.md b/packages/docs/gin/diagnostics.md new file mode 100644 index 00000000..85af83b3 --- /dev/null +++ b/packages/docs/gin/diagnostics.md @@ -0,0 +1,316 @@ +# Diagnostics + +When `engine.validate(expr)` finds a type error, you get a `Problems` list with structural paths like `['vars', 0, 'value', 'ifs', 0, 'condition']`. That's the validator's view. To produce a human-readable error pointing at the offending source line, gin pairs each rendered output with a span list — every character range in the rendered text traces back to the validator path that produced it. + +The result: compiler-style underlines, against either gin's TypeScript-flavored display or against the raw JSON program. + +## Two render targets + +| Target | Builder | What you get | +|---|---|---| +| Display (TS-flavored) | `engine.toCode(expr)` | Compact, readable form. Best for showing problems to humans or in LLM-facing prompts. | +| JSON (raw program) | `engine.toJSONCode(expr)` | The literal `JSON.stringify`-style form. Best when the LLM needs to edit the program tree by character offset. | + +Both produce a `Code` instance — `{ text: string, spans: Span[] }` — with spans tying every range back to a path. `formatProblem(code, problem)` and `formatProblems(code, problems)` resolve the path and emit the underlined output. + +## `formatProblem` — single problem, terse + +```typescript +import { createRegistry, createEngine, formatProblem } from '@aeye/gin'; + +const r = createRegistry(); +const engine = createEngine(r); + +const program = { + kind: 'define', + vars: [ + { name: 'x', type: { name: 'num' }, value: { kind: 'new', type: { name: 'text' }, value: 'wrong' } }, + ], + body: { kind: 'get', path: [{ prop: 'x' }] }, +}; + +const problems = engine.validate(r.parseExpr(program)); +const code = engine.toCode(r.parseExpr(program)); + +console.log(formatProblem(code, problems.list[0])); +``` + +Output: + +```text +const x: num = "wrong"; + ^^^^^^^ +error: var 'x' value type 'text' not compatible with declared 'num' +``` + +Single-problem renders default to no section headers and no line numbers — terse, one issue at a time. + +## `formatProblems` — multi-problem with sections + +```typescript +import { formatProblems } from '@aeye/gin'; + +console.log(formatProblems(code, problems)); +``` + +Output (with defaults): + +```text +── lines 5-7 ─────────────────── + 5 │ const x: num = "wrong"; + ^^^^^^^ + error: var 'x' value type 'text' not compatible with declared 'num' + 6 │ x; + 7 │ } + +── lines 12-14 ────────────────── + 12 │ if (1) { + ^ + warning: if condition should be bool, got 'num' + 13 │ x; + 14 │ } +``` + +Sections are contiguous blocks of lines containing one or more problems plus a configurable buffer of surrounding context (default 2 lines). Sections whose context windows overlap are merged so problems near each other share their surrounding code instead of repeating it. + +Options: + +| Option | Default | Effect | +|---|---|---| +| `contextLines` | `2` | Lines of code shown around each problem. | +| `sectionHeaders` | `true` | Show `── lines N-M ──` separators. | +| `lineNumbers` | `true` | Emit `N │ ` line-number gutter. | +| `color` | `false` | ANSI color codes for terminal output. | +| `maxProblems` | `Infinity` | Cap total problems shown; remainder appended as a count. | + +Problems whose path resolves to no span (e.g. validator errors at a node that didn't render visibly) fall through to a plain `: @ ` line appended after the sections. + +## JSON-target problem formatting + +When the LLM is editing the program as JSON (e.g. `ginny`'s `programmer` sub-agent), point it at JSON output instead: + +```typescript +const jsonCode = engine.toJSONCode(r.parseExpr(program)); +console.log(formatProblems(jsonCode, problems)); +``` + +Output: + +```text +── lines 4-9 ─────────────────── + 4 │ "vars": [ + 5 │ { + 6 │ "name": "x", + 7 │ "type": { "name": "num" }, + 8 │ "value": { "kind": "new", "type": { "name": "text" }, "value": "wrong" } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + error: var 'x' value type 'text' not compatible with declared 'num' + 9 │ } +``` + +Same problem, same path, different render target. Useful for LLM workflows where the agent's wire format IS the JSON — telling it "the error is at character offset 247" is far more actionable than handing it a structural path it has to walk itself. + +## Type definitions also have spans + +`Type.toCode()` and `Type.toJSONCode()` produce `Code` for type definitions, with spans that match the validator's paths into TypeDef structures. So validation errors against extensions / augmentations / new types format the same way: + +```typescript +const customType = r.parseObj({ + name: 'BadType', + props: { + age: { type: { name: 'num', options: { min: -1, max: 100 } } }, // negative min + }, +}); + +const problems = customType.validate(); +console.log(formatProblems(customType.toCode(), problems)); +``` + +Output: + +```text +type BadType { + age: num{min=-1, max=100} + ^^ + error: invalid-option: min cannot be negative for whole-mode num +} +``` + +This is what powers `ginny`'s `architect` sub-agent — when an LLM proposes a new type with an invalid constraint, the agent gets back a compiler-style error pointing at the offending option, not a structural path it has to interpret. + +## A complex example — recursive Fibonacci + +A complete program that defines a recursive lambda, computes Fibonacci via memoization, and uses several built-in surfaces. Hand-written here for clarity; in practice these come from an LLM: + +```typescript +import { createRegistry, createEngine } from '@aeye/gin'; + +const r = createRegistry(); +const engine = createEngine(r); + +// (1) A type for the memo: map. +// (2) A lambda fib(n: num): num that closes over `cache`. +// (3) Walk 0..15 and print each (n, fib(n)). + +const program = { + kind: 'define', + vars: [ + { + name: 'cache', + // map — keys are inputs, values are computed results. + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'num' }, V: { name: 'num' } } }, + }, + }, + { + name: 'fib', + value: { + kind: 'lambda', + // (n: num): num + type: { + name: 'function', + options: { + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, + returns: { name: 'num' }, + }, + }, + body: { + kind: 'if', + ifs: [ + // base case: n < 2 → n + { + condition: { + kind: 'get', + path: [ + { prop: 'args' }, + { prop: 'n' }, + { prop: 'lt' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }, + ], + }, + body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] }, + }, + // memo hit: cache.has(n) → cache[n] + { + condition: { + kind: 'get', + path: [ + { prop: 'cache' }, + { prop: 'has' }, + { args: { key: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] } } }, + ], + }, + body: { + kind: 'get', + path: [ + { prop: 'cache' }, + { key: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] } }, + ], + }, + }, + ], + // recursive case: result = fib(n-1) + fib(n-2); cache it; return. + else: { + kind: 'define', + vars: [ + { + name: 'result', + value: { + kind: 'get', + path: [ + { prop: 'recurse' }, + { args: { n: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'n' }, { prop: 'sub' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 1 } } }, + ], + } } }, + { prop: 'add' }, + { args: { other: { + kind: 'get', + path: [ + { prop: 'recurse' }, + { args: { n: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'n' }, { prop: 'sub' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }, + ], + } } }, + ], + } } }, + ], + }, + }, + ], + body: { + kind: 'block', + lines: [ + // cache[n] = result + { + kind: 'set', + path: [ + { prop: 'cache' }, + { key: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] } }, + ], + value: { kind: 'get', path: [{ prop: 'result' }] }, + }, + // return result + { kind: 'get', path: [{ prop: 'result' }] }, + ], + }, + }, + }, + }, + }, + ], + // body: collect 16 Fibonacci numbers via list.map. + body: { + kind: 'get', + path: [ + // list of 0..15 + { prop: 'list' }, // (would come from a global helper; left as a sketch) + // .map(n => fib(n)) + { prop: 'map' }, + { args: { + fn: { + kind: 'lambda', + type: { name: 'function', options: { + args: { name: 'obj', props: { value: { type: { name: 'num' } } } }, + returns: { name: 'num' }, + } }, + body: { + kind: 'get', + path: [ + { prop: 'fib' }, + { args: { n: { kind: 'get', path: [{ prop: 'args' }, { prop: 'value' }] } } }, + ], + }, + }, + } }, + ], + }, +}; + +const result = await engine.run(r.parseExpr(program)); +console.log(result.raw); +// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610] +``` + +What this exercises: + +- **`define` with closure semantics** — `cache` is captured by `fib`'s lambda body. Recursive calls via `recurse` (the lambda's self-reference) hit the same closed-over cache. +- **`if` with multiple branches** — base case, memo hit, recursive fallthrough. +- **`map` with `[key]` get and `set`** — `cache[n]` reads and writes through the map's defined `get` + `set`. +- **Nested `get` paths with multiple `args` steps** — chained method calls on `num` (`.sub(1).add(...)`) reduce to a sequence of path steps. +- **`list.map` with a lambda argument** — passing a callable into a method whose generic `R` resolves to `num`. + +In a real workflow, `engine.toCode(program)` renders this to compact TypeScript-flavored display, `engine.validate(program)` static-checks it before running, and `formatProblems(engine.toCode(program), problems)` shows compiler-style errors. If the LLM authored a typo (e.g. forgot `recurse`'s args), the path-to-span resolution lands the underline exactly under the offending node rather than a structural breadcrumb. + +## Read next + +- [Type System](./types.md) — what's being validated. +- [Expressions](./expressions.md) — what nodes the spans point at. +- [Registry](./registry.md) — `engine.toCode` / `engine.toJSONCode` / `engine.validate`. diff --git a/packages/docs/gin/expressions.md b/packages/docs/gin/expressions.md new file mode 100644 index 00000000..8fe9289e --- /dev/null +++ b/packages/docs/gin/expressions.md @@ -0,0 +1,132 @@ +# Expressions + +A gin program is a tree of `Expr` JSON objects. Every node has `kind: '...'` plus the fields that kind declares. Twelve kinds total — `new`, `get`, `set`, `define`, `block`, `if`, `switch`, `loop`, `lambda`, `template`, `flow`, `native`. + +## `new` — construct a value of a given type + +```json +{ "kind": "new", "type": , "value": } +``` + +If the type has `init`, `value` is parsed as `init.args` and the constructor runs. Otherwise `value` is parsed as `type` directly. With no `value`, returns `Value(type, type.create())` — the type's default. + +## `get` — read through a path + +```json +{ "kind": "get", "path": [, , ...] } +``` + +Steps walk left-to-right. Each step is one of: + +- `{prop: 'name'}` — named access. +- `{args: {...}}` — call the previous step (used after a method or any callable). +- `{key: }` — indexed access. + +The first step is always `{prop: ''}`. Result is the final step's value. + +## `set` — write through a path + +```json +{ "kind": "set", "path": [, ...], "value": } +``` + +Same path grammar as `get`, but the tail step writes. Returns `bool`: true on success, false if a safe-nav null/undefined short-circuited the walk. + +## `define` — bind locals into a child scope + +```json +{ "kind": "define", "vars": [{ "name": "x", "type": ..., "value": ... }, ...], "body": } +``` + +Each var is added to scope BEFORE the next var's value is evaluated, so later vars can reference earlier ones. The body runs with all vars in scope; its result is the define's value. + +## `block` — sequence of expressions + +```json +{ "kind": "block", "lines": [, ...] } +``` + +Lines run in order. Earlier lines are evaluated for their side effects (set, native calls, fns); the block's value is the LAST line's value. An empty block returns void. + +## `if` — conditional branching + +```json +{ "kind": "if", "ifs": [{ "condition": ..., "body": ... }, ...], "else": } +``` + +Each condition must be `bool`-typed. First branch whose condition is true wins. Without an else, a no-match if-expression returns void. + +## `switch` — value-based branching + +```json +{ "kind": "switch", "value": , "cases": [{ "equals": [...], "body": }], "else": } +``` + +The case wins if `value` equals ANY one of `equals`. Cases are NOT fall-through; only the matching case's body runs. + +## `loop` — iterate any iterable + +```json +{ "kind": "loop", "over": , "body": , "key": "k", "value": "v", "parallel": {...} } +``` + +Two evaluation modes by `over`'s static type: + +- **Iterable** (`get().loop` defined) — walked once. `key` / `value` bind to scope under those names (override defaults via the optional fields). +- **Bool while-loop** (`get().loopDynamic === true`) — `over` is RE-EVALUATED each iteration. The loop continues while truthy and exits the moment it becomes false. `bool` uses this. + +Optional `parallel: { concurrent?, rate? }` fans body execution out: `concurrent` caps simultaneous bodies, `rate` paces start times. The native iterator just calls `yield(k, v)`; the parallel orchestration sits in `LoopExpr.evaluate` so every iterable inherits it for free. + +Parallel composes with the dynamic mode: `bool over` plus `parallel: { concurrent: 3 }` fans the body out up to 3 in-flight, and `over` is re-evaluated against the outer scope every time a task COMPLETES (not when it starts). Accumulating side effects from the prior batch decide whether more tasks spawn. + +## `lambda` — callable closure + +```json +{ "kind": "lambda", "type": , "body": , "constraint": } +``` + +Inside the body, `args` is the call-site arguments obj and `recurse` is this lambda (for self-calls). Optional `constraint` runs before the body each call (must return `bool`); throws on false. + +## `template` — string interpolation + +```json +{ "kind": "template", "template": "Hello {name}!", "params": } +``` + +Each `{name}` placeholder in the string is replaced with the stringified `params.name`. Compiles to a JS template literal in `toCode` rendering when params is a `new obj` literal. + +## `flow` — non-local control flow + +```json +{ "kind": "flow", "action": "break" | "continue" | "return" | "exit" | "throw", "value": ..., "error": ... } +``` + +| Action | Effect | +|---|---| +| `break` / `continue` | Only valid inside a `loop`. | +| `return` | Unwinds to the enclosing lambda or fn body; `value` becomes the result. | +| `exit` | Unwinds all the way to `engine.run`; `value` becomes the program result. | +| `throw` | Raises `error`; caught by a path step's `catch:` handler. | + +## `native` — escape hatch to a registered native impl + +```json +{ "kind": "native", "id": "my.native.id", "type": } +``` + +Calls into a JS/TS function registered via `registry.setNative(id, impl)`. Most natives are referenced indirectly — `num.add`'s prop type carries `{kind: 'native', id: 'num.add'}` as its get expression, so a path call to `.add` dispatches without any explicit `native` node in user code. You'd hand-write a `native` node when authoring a custom loop ExprDef or a method whose impl lives outside gin. + +## Parsing + +gin has TWO levels of parsing — they compose: + +1. **JSON → runtime objects.** `registry.parse(typeDef)` turns a `TypeDef` JSON into a `Type` instance; `registry.parseExpr(exprDef, scope?)` turns an `ExprDef` into an `Expr`. Inverse: `type.toJSON()` / `expr.toJSON()`. Round-trips losslessly. +2. **Runtime data → typed values.** Once you have a `Type`, calling `type.parse(jsonData)` validates the data and returns a `Value` — the runtime currency. A `Value` is a `{type, raw}` pair where `raw` is the JS storage shape. `value.toJSON()` produces the JSON shape; `type.encode(value.raw)` does the same at the type level. + +Both levels are scope-aware. Generic placeholders (`AliasType`) resolve through the scope passed to parse — that's how a `CallStep`'s `generic: { R: }` map flows into the called signature without rebuilding the type tree. + +## Read next + +- [Type System](./types.md) — the four surfaces (props / get / call / init), generics, extensions. +- [Registry](./registry.md) — registering types, natives, and ExprClasses. +- [Built-in Types](./built-ins.md) — what every program starts with. diff --git a/packages/docs/gin/index.md b/packages/docs/gin/index.md new file mode 100644 index 00000000..f9d02d96 --- /dev/null +++ b/packages/docs/gin/index.md @@ -0,0 +1,57 @@ +# gin + +> A JSON-based programming language and type system designed for LLMs to author, validate, and execute typed programs at runtime. + +`gin` is part of the `@aeye` family but it isn't a provider, a component framework, or a wrapper around an LLM. It's the **runtime an LLM authors programs against** — a real type system with proper generics, structural compatibility, and extension-based inheritance, plus an expression language serialized as plain JSON. + +```bash +npm install @aeye/gin zod +``` + +## Why a separate language? + +When an LLM produces "code" today, you get unstructured text that you `eval` (terrifying) or constrain with structured outputs (better, but the schema explodes for anything non-trivial). Neither approach gives you: + +- **Compile-time validation before execution.** A gin program is parsed and type-checked against the registry; broken programs are rejected before they run. +- **Round-trippable JSON.** Programs survive `JSON.stringify` / `JSON.parse` losslessly. They can be persisted, indexed, edited, and replayed. +- **A real type system.** Generics, interfaces, structural subtyping, extensions, augmentations — the things that make TypeScript code reusable, available to the LLM at authoring time. +- **Pluggable native dispatch.** Methods on `num`, `text`, `list`, `date`, etc. are gin methods whose implementations live in JS — you can register your own natives and the LLM calls them as if they were built in. + +The result: you can ship an LLM tool that writes a typed function, tests it, persists it, and lets future calls invoke it directly. `@aeye/ginny` does exactly that as a CLI; `@aeye/gin` is the engine. + +## Where it fits + +``` +┌──────────────────────────────────────────────┐ +│ Your application │ +│ ↓ │ +│ @aeye/ai ← tool-calling, model select │ +│ @aeye/core ← Prompt / Tool / Agent │ +│ @aeye/gin ← LLM-authorable runtime │ +│ ↓ │ +│ Your registry of native fns + types │ +└──────────────────────────────────────────────┘ +``` + +`@aeye/gin` is consumed two ways: + +- **Standalone** — author programs from a TS host (or via an LLM elsewhere) and run them via `engine.run(expr)`. +- **Through `@aeye/ai`** — register a tool whose schema is `buildSchemas(registry)` (the LLM-facing schema for ExprDefs) and let the LLM author programs as tool arguments. `ginny` does this. + +## Quick orientation + +Three concepts to grasp: + +1. **Types** — every value has a type; types describe shape, methods, equality, and how they parse JSON. Built-ins (`num`, `text`, `list`, `obj`, `date`, `duration`, `color`, ...) ship with the registry; you add your own via `extend` (real subtyping) or `augment` (gap-filling on existing types). +2. **Expressions** — twelve `kind`s of `ExprDef` JSON nodes (`new`, `get`, `set`, `define`, `block`, `if`, `switch`, `loop`, `lambda`, `template`, `flow`, `native`). A program is a tree of these. +3. **The Registry** — the only class you really need. It owns the type catalog, native function bindings, expression-class dispatch, and parsing. `createRegistry()` ships with everything pre-registered. + +## Read next + +- [Type System](./types.md) — props / get / call / init, generics, compatibility, extensions, augmentations. +- [Expressions](./expressions.md) — the 12 kinds and what each carries. +- [Registry](./registry.md) — registration patterns, native bindings, schema generation for LLMs. +- [Built-in Types](./built-ins.md) — the catalog every program starts with. +- [Diagnostics](./diagnostics.md) — `formatProblem` / `formatProblems` for compiler-style errors against gin or JSON output. + +For a complete application built on gin, see [`@aeye/ginny`](../examples/ginny.md). diff --git a/packages/docs/gin/registry.md b/packages/docs/gin/registry.md new file mode 100644 index 00000000..5e51921f --- /dev/null +++ b/packages/docs/gin/registry.md @@ -0,0 +1,184 @@ +# Registry + +The `Registry` is the only class you really need to construct. Every other class (`Type`, `Expr`, `Engine`, `Value`, `Path`, ...) is reachable through it. `createRegistry()` ships with every built-in type, native, and Expr class pre-registered — start there. + +## Key methods + +| Method | Purpose | +|---|---| +| `parse(def)` / `parseExpr(def, scope?)` | TypeDef / ExprDef → runtime | +| `define(cls)` | Register a built-in Type class for JSON dispatch | +| `register(type)` | Register a named Type instance (typically an Extension) | +| `lookup(name)` | Look up a Type by name (registered → built-in fallback) | +| `setNative(id, impl)` | Wire a JS function as a gin native | +| `getNative(id)` | Read a native back | +| `defineExpr(cls)` | Register an ExprClass (12 ship; you rarely add more) | +| `extend(base, { name, ... })` | Create a named Extension (real subtype) | +| `augment(name, { props?, get?, call?, init? })` | Add to an existing type by name | +| `augmentation(name)` | Read augmentation back | +| `like(type)` | Pick a registered concrete type compatible with a constraint | + +## Builder methods + +The builder methods construct runtime types without going through JSON. They're sugar for `parse`: + +```typescript +r.num() // num +r.num({ min: 0, max: 100, whole: true }) // num with constraints +r.text() // text +r.text({ minLength: 1, pattern: '...' }) // constrained text +r.list(r.num()) // list +r.map(r.text(), r.num()) // map +r.obj({ name: { type: r.text() }, age: { type: r.num(), optional: true } }) +r.fn(args, returns) // function type +r.fn(args, returns, throws) // with throws +r.fn(args, returns, throws, generic) // with generics +r.iface({ ... }) // structural interface +r.method(args, returns, nativeId) // method whose impl is a native +r.prop(type, nativeId) // value prop with native getter +``` + +Pass them around as values; combine them. Most user code that builds types programmatically uses the builders rather than hand-writing TypeDef JSON. + +## The Engine + +`createEngine(registry)` builds an Engine that owns evaluation, validation, and type-inference walks: + +| Method | Purpose | +|---|---| +| `engine.run(expr, extras?)` | Execute a program. Returns `Value`. | +| `engine.validate(expr)` | Static analysis. Returns `Problems` (validation errors / warnings). | +| `engine.typeOf(expr)` | Static type inference. Returns `Type` or undefined. | +| `engine.toCode(expr)` | Render to a TypeScript-flavored display string. | +| `engine.toJSONCode(expr)` | Render to JSON Code with span tracking (see [Diagnostics](./diagnostics.md)). | + +Programs run in a scope. Scope variables come from `extras` plus globals registered via `engine.registerGlobal(name, { type, value })`. Each sub-call (lambda body, fn invocation) creates a child scope — no implicit leaks between branches. + +## Native functions + +Natives are JS/TS functions registered against a string id and called via `{ kind: 'native', id: '...' }` expressions OR indirectly through method props whose `get` expression is a native call. + +```typescript +r.setNative('Email.domain', (scope, reg) => { + const self = scope.get('this')!.raw as string; + return val(reg.text(), self.split('@')[1] ?? ''); +}); +``` + +Inside a native: + +- `scope.get(name)` returns a `Value` for a scoped binding (or undefined). +- `scope.get('this')` for instance methods. +- `scope.get('args')` for callable types — the args obj `Value`. +- `val(type, raw)` constructs a fresh `Value`. + +Most natives feel mechanical: read inputs from scope, do the work, return `val(type, result)`. The interesting ones are loops and async natives, which use the same shape but read `yield` from scope and call it with `(key, value)` pairs. + +## Schema generation for LLMs + +`buildSchemas(registry)` returns a Zod schema describing every valid `ExprDef` against THIS registry — including augmentations and extensions. Pass that schema as a Tool's parameter and the LLM can only produce well-typed gin programs: + +```typescript +import { buildSchemas } from '@aeye/gin'; + +const writeProgram = ai.tool({ + name: 'write_program', + schema: z.object({ + program: buildSchemas(registry).expr, // any ExprDef the LLM authors must validate + }), + call: async ({ program }, _, ctx) => { + const expr = registry.parseExpr(program); + const result = await engine.run(expr); + return { value: result.toJSON(), type: result.type.toCodeDefinition() }; + }, +}); +``` + +`buildSchemas` regenerates if you add types or natives at runtime — call it after registration to capture additions. + +## Putting it together + +```typescript +import { createRegistry, createEngine, val, Init } from '@aeye/gin'; + +const r = createRegistry(); + +// 1. Extension — real subtype with its own surface. +const Email = r.extend( + r.text({ pattern: '^[^@]+@[^@]+$', minLength: 3 }), + { + name: 'Email', + docs: 'A text value matching a basic email shape', + props: { + domain: r.method({}, r.text(), 'Email.domain'), + }, + }, +); +r.register(Email); + +// 2. Native — implements Email.domain. +r.setNative('Email.domain', (scope, reg) => { + const self = scope.get('this')!.raw as string; + return val(reg.text(), self.split('@')[1] ?? ''); +}); + +// 3. Augmentation — adds clamp01 + a percent-based constructor to num. +r.augment('num', { + props: { + clamp01: r.method({}, r.num({ min: 0, max: 1 }), 'num.clamp01'), + }, + init: new Init({ + args: r.obj({ percent: { type: r.num({ min: 0, max: 100 }) } }), + run: { kind: 'native', id: 'num.fromPercent' }, + }), +}); +r.setNative('num.clamp01', (scope, reg) => { + const n = scope.get('this')!.raw as number; + return val(reg.num({ min: 0, max: 1 }), Math.max(0, Math.min(1, n))); +}); +r.setNative('num.fromPercent', (scope, reg) => { + const args = scope.get('args')!.raw as Record; + return val(reg.num({ min: 0, max: 1 }), (args.percent.raw as number) / 100); +}); + +// 4. Run a program. (Hand-written here; usually authored by an LLM.) +const engine = createEngine(r); + +const program = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [ + // `new num({percent: 75})` — augmented init runs; result is 0.75. + { name: 'opacity', value: { kind: 'new', type: { name: 'num' }, value: { percent: 75 } } }, + { name: 'address', value: { kind: 'new', type: { name: 'Email' }, value: 'team@example.com' } }, + ], + body: { + kind: 'block', + lines: [ + { kind: 'get', path: [{ prop: 'opacity' }, { prop: 'clamp01' }, { args: {} }] }, + { kind: 'get', path: [{ prop: 'address' }, { prop: 'domain' }, { args: {} }] }, + ], + }, + }, + ], +}; + +const result = await engine.run(program); +console.log(result.raw); // 'example.com' +``` + +What this exercises: + +- `r.extend(...)` produces `Email`, a real subtype of `text`. Static analysis treats Email as text everywhere text is expected; tighter tests pass on Email-only values. +- `r.augment('num', ...)` adds `clamp01` AND `init` to the canonical `num` type. Every num — including extensions over num — picks them up. `new num({percent: 75})` flows through the augmented init. +- `r.setNative(id, impl)` wires the JS implementations. Any path call referencing those native ids dispatches through them. +- `engine.run(program)` evaluates the JSON tree, validating types as it walks. + +Augmentations and extensions live on the registry. Pass that registry to the engine — and to any prompt schema generator (`buildSchemas(r)`) — so the LLM authoring programs sees the full surface. + +## Read next + +- [Built-in Types](./built-ins.md) — the catalog every program starts with. +- [Diagnostics](./diagnostics.md) — `formatProblem` / `formatProblems` and the JSON Code span model. diff --git a/packages/docs/gin/types.md b/packages/docs/gin/types.md new file mode 100644 index 00000000..5eba24eb --- /dev/null +++ b/packages/docs/gin/types.md @@ -0,0 +1,121 @@ +# Type System + +Every gin Type — built-in or user-defined — exposes up to four surfaces. These are the only knobs you have for shaping runtime behavior. Any type can opt into any combination: a single type can be callable, indexable, looped over, AND have named props. There's no inheritance hierarchy that gates this — `obj` doesn't have privileged status; nor does `function`. Whatever surfaces a type defines, the engine dispatches against them. + +## The four surfaces + +### `props` — named methods and fields + +A type's `props` map is the static surface accessed by name. Each prop is one of: + +- A **value-typed prop** — `length: num` on `text`, `r: num` on `color`. Read by walking a path step `{prop: 'length'}`. +- A **method** — `add(other: num): num` on `num`, `slice(start, end?): text` on `text`. A method is just a prop whose type is a `function`; invoking it via `[{prop: 'add'}, {args: {other: 3}}]` runs the underlying expression / native. + +The same path step `{prop: 'name'}` works for both — a method just has a callable type, so you follow it with a `{args: ...}` step. + +### `get` — keyed access (and looping) + +When a type defines `get`, it supports `[key]` access. The `GetSet` spec carries: + +- `key` — the type a key must satisfy (`num` for lists, the field-name union for `obj`, `text` for `map`, ...). +- `value` — what indexed access produces. +- Optional `loop` expression — drives `loop` iteration. When present, the type is iterable via `{kind: 'loop', over: , body: ...}`. The loop expression runs with `this` (the iterable) and `yield` (a callable taking `{key, value}`) bound in scope, and calls `yield` once per pair. +- Optional `loopDynamic: true` — flags while-loop semantics (the `over` expression is re-evaluated each iteration). `bool` uses this. + +### `call` — make the type callable + +When a type defines `call`, values of that type can be invoked. The `Call` spec carries `args` (an obj-shaped type), `returns` (the result type), optional `throws`, and optional `get`/`set` expressions that implement the call. `function` is the obvious example, but augmentation can make any type callable. + +### `init` — constructor for `new` + +When `init` is defined, `{kind: 'new', type: T, value: }` parses `` against `init.args` and runs `init.run` with `{this, args}` in scope — `this` is a default-constructed value and `args` is the parsed input. The expression returns either a fresh value (if the run returns one) or the mutated `this`. + +Without `init`, `new T(value)` just runs `T.parse(value)` directly. `duration` and `color` ship with `init` defined; the LLM can author `new color({r: 255, g: 0, b: 0})` and the constructor packs the channels into a 32-bit integer. + +The `value` slot of a `new` expression automatically reflects `init.args` in the LLM-facing schema — you don't need to write per-type `toNewSchema` overrides for that case. + +## Generics + +A type can declare `generic` parameters — each entry's value is a **constraint**, not a default. Bare `{name: 'R'}` inside the signature is an unresolved placeholder (gin's `AliasType`); concrete resolution happens when a call site supplies a binding. + +| Constraint | Meaning | +|---|---| +| `R: any` | No constraint. Any type accepted as a binding. | +| `R: text \| obj` | Bindings must be assignable to `text \| obj`. Anything else is rejected at the call site with a clear error. | +| `R: ` | Structural constraint. Bindings must satisfy the interface. | +| `R: alias('R')` | Self-reference. Equivalent to "no constraint"; the satisfies check is skipped. | + +Bindings are validated when a `CallStep` provides them. There is no implicit default — if you don't bind, the parameter stays a placeholder and downstream type checks against it are permissive. + +Generics show up natively in: + +- Function types (`(args): R`). +- Parameterized types (`list`, `map`, `optional`). +- Methods that introduce their own type parameters (`list.map(fn): list`). + +## Type compatibility + +`a.compatible(b)` answers "every value of b is also a valid value of a" — i.e. `b` is assignable to `a`. Used by: + +- **Path validation** — a method call's args must be compatible with the called fn's args type. +- **Structural interface satisfaction** — does this object have all the props an interface requires? +- **Edit safety** — can this new type definition replace the old one without breaking callers? Check both directions. + +For `obj`: `a.compatible(b)` requires every required field of `a` to exist on `b` with a compatible per-field type. Optional fields on `a` may be absent from `b` (the missing field defaults to undefined, which optional accepts). Extra fields on `b` are ignored. `opts.exact` tightens this to exact field-set match. + +For `function`: bivariant on args (matches TypeScript's default method-arg rule), covariant on returns. Most code wants the bivariant form; edit-compat tooling splits args + returns and checks each side directionally to enforce strict TS-style variance. + +## Extensions + +`registry.extend(base, { name, ... })` creates a **named subtype** that overlays additions on a base. Extensions can: + +- **Add props** — new fields and methods. +- **Override `get` / `call` / `init`** — replace any of the base's surfaces. +- **Narrow options** — `Email` extending `text({pattern: ...})` carries the tighter pattern at runtime. +- **Add a constraint Expr** — a runtime predicate every value must satisfy. Evaluated on `engine.validateValue(v)`; runs with `this` bound to the value. +- **Declare `generic`** — extensions can have their own type params. + +```typescript +const Email = r.extend( + r.text({ pattern: '^[^@]+@[^@]+$', minLength: 3 }), + { + name: 'Email', + docs: 'A text value matching a basic email shape', + props: { + domain: r.method({}, r.text(), 'Email.domain'), + }, + }, +); +r.register(Email); +``` + +Extensions delegate everything to the base via `Type.compatible`, `Type.props` composition, etc. `Email extends text` is a real subtype: every Email is a valid text; tighter tests pass on Email-only values. + +## Augmentations + +`registry.augment(name, { props?, get?, call?, init? })` adds to an **existing** type by name — works for built-ins (`'num'`, `'text'`, `'date'`, `'timestamp'`, ...) and registered named types. Augmentation is gentler than extension: + +- `props` are MERGED into the type's existing props. Intrinsic names win on conflict — you can't override `num.add` by augmenting num. +- `get` / `call` / `init` are applied IFF the type has none of its own. Augmentation FILLS GAPS — give `date` a `get` so it iterates, make `timestamp` callable, give `text` a constructor — but never overrides what's already there. + +```typescript +r.augment('num', { + props: { + clamp01: r.method({}, r.num({ min: 0, max: 1 }), 'num.clamp01'), + }, + init: new Init({ + args: r.obj({ percent: { type: r.num({ min: 0, max: 100 }) } }), + run: { kind: 'native', id: 'num.fromPercent' }, + }), +}); +``` + +The augmented surface flows through every consumer: path-walks dispatch against augmented props; static analysis sees them; code rendering shows them. No subclassing or wrapper required. + +When you want to genuinely REPLACE behavior (not just add), use an Extension — extensions own their entire surface and can override freely. + +## Read next + +- [Expressions](./expressions.md) — the 12 expression kinds. +- [Registry](./registry.md) — `extend` / `augment` / `register` / `setNative` patterns. +- [Built-in Types](./built-ins.md) — the catalog every program starts with. diff --git a/packages/docs/guides/strict-mode.md b/packages/docs/guides/strict-mode.md new file mode 100644 index 00000000..6e9d0fee --- /dev/null +++ b/packages/docs/guides/strict-mode.md @@ -0,0 +1,169 @@ +# Strict Mode + +Strict mode is the LLM provider feature that grammar-constrains tool inputs and structured output to your JSON Schema — the model can't return malformed arguments or off-schema fields. `@aeye` reconciles each provider's flavor of strict (OpenAI, Anthropic, Google) under one config knob so the same Zod schema works everywhere. + +## The `strict` flag + +`Tool.strict` and `Prompt.strict` are `boolean | number`: + +| Value | Meaning | +|---|---| +| `true` | **Require** strict. Selection filters out models that don't declare strict support — request fails if none qualify. | +| `false` | **Force** lenient. Standard JSON Schema, no `strict: true` on the wire. | +| `number > 0` (default `1`) | **Prefer** strict, accept fallback. The number is a priority — used when there are more strict-requesting items than the model's per-request budget allows. | +| omitted | Treated as `1`. | + +```typescript +import { Tool } from '@aeye/core'; +import z from 'zod'; + +new Tool({ + name: 'lookup', + description: 'Look up a record by id', + schema: z.object({ id: z.string() }), + // strict omitted → priority 1 (best-effort) + call: async (args) => fetch(`/api/${args.id}`).then(r => r.json()), +}); + +new Tool({ + name: 'critical-action', + schema: z.object({ /* ... */ }), + strict: true, // hard requirement + call: async (args) => { /* ... */ }, +}); + +new Tool({ + name: 'optional-tool', + schema: z.object({ /* ... */ }), + strict: 100, // very high preference — wins budget allocation + call: async (args) => { /* ... */ }, +}); + +new Tool({ + name: 'experimental', + schema: z.object({ /* ... */ }).passthrough(), + strict: false, // never strict; tolerate extra fields + call: async (args) => { /* ... */ }, +}); +``` + +The default of `1` means existing callers get strict where the chosen model supports it and lenient elsewhere — without changing anything. Set `true` only when strict is non-negotiable. + +## How `strict: ` differs from `strict: true` + +`true` participates in **selection**: a request with `strict: true` on any tool will only run against a strict-tool-capable model. If you've configured a mix of strict-capable and non-strict-capable providers and selection picks the wrong one, `strict: true` is the corrective lever. + +`` participates in **scoring** but never filters. Selection prefers strict-capable models but accepts any. At request build time, the chosen model determines the wire format: + +- Model supports strict → emitted strict, with the number used as priority for slot allocation when budget is tight. +- Model doesn't support strict → emitted lenient, silent fallback. + +For most apps `strict: 1` (the default) is correct. Reach for `strict: true` when a tool must validate, and for higher priority numbers when you have many strict-flagged tools and want to control which ones win the budget on a tight provider like Anthropic. + +## Per-request budgets + +Some providers cap the per-request strict load. Anthropic's documented limits: + +- 20 strict tools per request. +- 24 total optional parameters across all strict schemas. +- 16 union-type parameters across all strict schemas. + +When a request exceeds budget, `@aeye` walks tools in **descending priority order** (`true` → ∞, then numbers high-to-low) and degrades the over-budget tail to LENIENT silently — rather than failing the API call: + +```typescript +// 25 tools all wanting strict, against an Anthropic model: +const tools = Array.from({ length: 25 }, (_, i) => new Tool({ + name: `tool-${i}`, + schema: z.object({ /* ... */ }), + strict: i < 5 ? 100 : 1, // first 5 high-priority, rest default + call: async () => {/*...*/}, +})); + +// Wire result: +// - Tools 0-4 (priority 100): all 5 ship strict. +// - Tools 5-19 (priority 1): 15 more ship strict (budget = 20 total). +// - Tools 20-24: ship lenient. +// - API call succeeds; the lenient tools just don't have grammar enforcement. +``` + +OpenAI doesn't publish hard caps, so its budget is unbounded. Google Gemini also has no documented per-request cap. + +## Format families + +A model's strict-mode JSON Schema dialect is one of three families. Each provider's strict mode is its own beast — the dialects are NOT interchangeable: + +| Family | What's distinctive | +|---|---| +| `openai` | Records → array-of-pairs, tuples → numeric-key objects, `optional → T \| null`, all fields required. | +| `anthropic` | Closed objects + all-required honored, but no recursive schemas, no length / numeric constraints, per-request slot budgets. | +| `google` | Standard `prefixItems`, `$ref: '#'` recursion, `propertyOrdering` emitted, restricted format whitelist (date-time / date / time only). | + +`@aeye/core` ships seven `FormatDescriptor` constants encoding all this: `OPENAI_STRICT`, `ANTHROPIC_STRICT`, `GOOGLE_STRICT`, `LENIENT`, plus three non-strict family aliases. See the [`@aeye/core` README](https://github.com/ClickerMonkey/aeye/blob/main/packages/core/README.md#strict-mode) for the full descriptor table and how to register a custom one. + +## Wiring up the curated table + +`@aeye/models` ships `strictSupport` — a hand-maintained `ModelOverride[]` listing the model families known to support strict (gpt-4o+, claude 4.5+, gemini 2.0+). Splat it into your `AI` config: + +```typescript +import { models, strictSupport } from '@aeye/models'; + +const ai = AI.with() + .providers({ openai, anthropic, google }) + .create({ + models, + modelOverrides: [...strictSupport], + }); +``` + +Without it, models default to lenient even when their underlying API supports strict. With it: + +- `gpt-4o`, `gpt-4.1`, `gpt-5`, `o1`/`o3`/`o4` get the OpenAI dialect. +- AWS Bedrock + Claude 4.5 / Sonnet 4 / Haiku 4 / Opus 4 get the Anthropic dialect. +- OpenRouter `openai/*`, `anthropic/*`, `google/gemini-2+` get their respective dialects. + +You can mix it with your own overrides: + +```typescript +modelOverrides: [ + ...strictSupport, + // Your overrides: + { modelPattern: /gpt-4/, overrides: { tier: 'flagship' } }, + // Mark a custom model as strict-capable: + { provider: 'my-provider', modelId: 'flagship', overrides: { strictFormat: 'openai' } }, +], +``` + +## Auto-resolution and `strictFormat` + +Each `ModelInfo` has a `strictFormat?: 'openai' | 'anthropic' | 'google' | 'none'` field: + +- Set to a family name → opts the model into strict (auto-derives the `'toolsStrict'` capability) and pins the dialect. +- Set to `'none'` → explicitly opts out, even if some upstream source had marked the model strict-capable. +- Unset → the dialect is auto-resolved at request-build time via `resolveStrictFormat(model)`: + 1. `model.provider` if it matches a family name (covers direct `openai` / `anthropic` / `google` providers). + 2. The `[family]/...` prefix of `model.id` (covers `openai/gpt-4o` on OpenRouter). + +The fallback resolves the *dialect* but does **not** auto-add the `'toolsStrict'` capability. The capability comes from explicit `strictFormat` (or from a scraper that sets it directly). This avoids the failure mode where every legacy gpt-3.5-turbo silently gets treated as strict-capable and crashes against a real strict request. + +## Validation roundtrip + +When a strict-capable model returns its dialect's wire shape (e.g. an array-of-pairs record from OpenAI), the Prompt's validator needs to accept that shape. `@aeye` handles this transparently — the chosen descriptor id is pinned on the request before it's sent, and the same descriptor's `strictify` rewrite is applied to the schema during validation. The cache makes both calls hit the same Zod object reference. + +You don't have to do anything for this to work — but if you're authoring a custom Prompt validator or post-processing tool args yourself, use `strictify(schema, descriptor)` from `@aeye/core` to get the matching shape. See the [core README](https://github.com/ClickerMonkey/aeye/blob/main/packages/core/README.md#strict-mode) for the schema utility reference. + +## When to override + +| Situation | What to do | +|---|---| +| You want every tool strict, no fallback. | `strict: true` on each tool, and add a strict-capable model to your config. | +| You have many tools and Anthropic is in the mix. | Default `strict: 1` plus `strict: ` on the most-important ones. The budget allocator picks the right ones to ship strict. | +| You want NO strict on a tool that has a recursive schema. | `strict: false` — Anthropic strict can't represent recursion, and you'd rather opt out explicitly than rely on silent fallback. | +| A model in your config supports strict but isn't in `strictSupport`. | Add a custom override: `{ provider, modelId, overrides: { strictFormat: 'openai' } }`. | +| `strictSupport` marks a model as strict-capable but it's actually broken. | Override with `strictFormat: 'none'` to clear the capability. | + +## Related + +- [`@aeye/core` strict mode](https://github.com/ClickerMonkey/aeye/blob/main/packages/core/README.md#strict-mode) — the descriptor / schema-utility layer. +- [Tool Calling guide](./tool-calling.md) — strict applies to tool input schemas. +- [Structured Output guide](./structured-output.md) — strict applies to response schemas too. +- [Models concept](../concepts/models.md) — `strictSupport` and the curated table. diff --git a/packages/docs/guides/structured-output.md b/packages/docs/guides/structured-output.md index 807fa976..84411221 100644 --- a/packages/docs/guides/structured-output.md +++ b/packages/docs/guides/structured-output.md @@ -37,27 +37,28 @@ const result = await analyzer.get('result', { ## Strict Mode -By default, `strict: true` transforms the schema for strict JSON Schema compliance (required by OpenAI's structured outputs): +`Prompt.strict` is `boolean | number` (default: `1`): + +| Value | Meaning | +|---|---| +| `true` | Hard requirement — selection filters to strict-capable models. | +| `false` | Force lenient. Standard JSON Schema, no `strict` flag on the wire. | +| `number > 0` (default `1`) | Best-effort preference. Strict on models that support it, lenient fallback elsewhere. | ```typescript const prompt = ai.prompt({ schema: z.object({ /* ... */ }), - strict: true, // default — all properties required, no additionalProperties + // strict omitted → priority 1 (best-effort, default) }); -``` - -Set `strict: false` for more lenient schemas: -```typescript -const prompt = ai.prompt({ - schema: z.object({ - required: z.string(), - optional: z.string().optional(), // allowed in non-strict mode - }), - strict: false, +const strictPrompt = ai.prompt({ + schema: z.object({ /* ... */ }), + strict: true, // require a strict-capable model }); ``` +`@aeye` reconciles each provider's strict-mode dialect (OpenAI / Anthropic / Google) so the same Zod schema works everywhere — see the [Strict Mode guide](./strict-mode.md) for format families, per-request budgets, the curated `strictSupport` overrides, and how to register a custom dialect. + ## Dynamic Schema The schema can change based on context: diff --git a/packages/docs/guides/tool-calling.md b/packages/docs/guides/tool-calling.md index 0c7f270a..d05a4702 100644 --- a/packages/docs/guides/tool-calling.md +++ b/packages/docs/guides/tool-calling.md @@ -124,6 +124,28 @@ const tools = await prompt.get('tools', input); // tools is an array of { tool, result } objects ``` +## Strict Mode + +`Tool.strict` is `boolean | number` (default: `1`). When set, the LLM grammar-constrains tool arguments to your schema — no malformed args, no off-schema fields: + +```typescript +const calc = ai.tool({ + name: 'calculate', + schema: z.object({ expression: z.string() }), + // strict omitted → priority 1 (best-effort, default) + call: async ({ expression }) => ({ result: eval(expression) }), +}); + +const critical = ai.tool({ + name: 'transfer', + schema: z.object({ amount: z.number(), to: z.string() }), + strict: true, // hard requirement — selection rejects non-strict-capable models + call: async ({ amount, to }) => { /* ... */ }, +}); +``` + +`@aeye` reconciles each provider's strict-mode dialect (OpenAI / Anthropic / Google) and handles per-request budgets so a request with many strict tools degrades gracefully. See the [Strict Mode guide](./strict-mode.md) for the full picture, including the curated `strictSupport` overrides you'll want to wire into your `AI` config. + ## Error Handling Tool errors are caught and reported back to the model, which can retry or adjust: diff --git a/packages/docs/index.md b/packages/docs/index.md index 345d9499..998eacc2 100644 --- a/packages/docs/index.md +++ b/packages/docs/index.md @@ -74,4 +74,5 @@ console.log(result?.suggestion); | [`@aeye/openrouter`](/providers/openrouter) | OpenRouter multi-provider gateway | | [`@aeye/replicate`](/providers/replicate) | Replicate open-source model provider | | [`@aeye/aws`](/providers/aws) | AWS Bedrock provider (Converse API) | +| [`@aeye/gin`](/gin/) | JSON-based programming language and type system for LLM-authored programs | | `@aeye/models` | Auto-generated model registry with pricing and capabilities | diff --git a/packages/gin/README.md b/packages/gin/README.md index af38f6f7..e46d4a49 100644 --- a/packages/gin/README.md +++ b/packages/gin/README.md @@ -13,431 +13,686 @@ pluggable registry of native functions. npm install @aeye/gin zod ``` -## Why gin? - -LLMs are good at JSON. They're less good at language grammars with -balanced parens, significant whitespace, and rule-based parsers. gin -inverts the traditional approach: - -- **Programs are JSON trees.** Every expression has a `kind` discriminator. - Every type has a `name`. Serialization is free. -- **Types are first-class values.** A `typ` slot accepts any registry - type compatible with `T` — including user-defined extensions — and - narrows its ExprDef schema so the LLM only sees valid choices. -- **Structural + extension typing.** `Task extends obj` inherits obj's - shape and methods while adding new props, constraint predicates, and - overrides. Compatibility is decided by structure, not name. -- **Per-class Zod schemas at every layer.** `toSchema()` schemas the - TypeDef JSON. `toValueSchema()` schemas runtime data. `toNewSchema()` - schemas `new` expressions. `toInstanceSchema()` schemas narrow-match - TypeDef JSON for containers that constrain registered types. The LLM - always gets a tight Zod union of exactly what's valid at that slot. -- **Native functions.** Register any JS/TS function as a `fn<...>` type; - calls from gin programs dispatch to your implementation. - -## Quick start - -`createRegistry()` ships with every built-in type and native -implementation pre-registered. `createEngine(r).run(expr)` evaluates a -program. - -### 1. `let x = 2; return x.add(3)` → `5` +--- + +## The type system + +Every Type — built-in or developer-defined — exposes up to four +surfaces. These are the only knobs you have for shaping runtime +behavior: + +### `props` — named methods and fields + +A type's `props` map is the static surface accessed by name. Each prop +is one of: + +- A **value-typed prop** — `length: num` on `text`, `r: num` on `color`. + Read by walking a path step `{prop: 'length'}`. +- A **method** — `add(other: num): num` on `num`, `slice(start, end?): text` + on `text`. A method is just a prop whose type is a `function` — + invoking it via `[{prop: 'add'}, {args: {other: 3}}]` runs the + underlying expression / native. + +The same path step `{prop: 'name'}` works for both — a method just has +a callable type, so you follow it with a `{args: ...}` step. -```ts -import { createRegistry, createEngine } from '@aeye/gin'; +### `get` — keyed access (and looping) + +When a type defines `get`, it supports `[key]` access. The `GetSet` +spec carries: + +- `key` — the type a key must satisfy (`num` for lists, the field-name + union for `obj`, `text` for `map`, ...). +- `value` — what indexed access produces. +- Optional `loop` expression — drives `loop` iteration. When present, the + type is iterable via `{kind: 'loop', over: , body: ...}`. + The loop expression runs with `this` (the iterable) and `yield` (a + callable taking `{key, value}`) bound in scope, and calls `yield` + once per pair. Native loops live in `gin/src/natives/*.ts`; you can + register custom ones via augmentation. +- Optional `loopDynamic: true` — flags while-loop semantics (the + `over` expression is re-evaluated each iteration). `bool` uses this. + +### `call` — make the type callable + +When a type defines `call`, values of that type can be invoked. The +`Call` spec carries `args` (an obj-shaped type), `returns` (the +result type), optional `throws`, and optional `get`/`set` expressions +that implement the call. `function` is the obvious example, but +augmentation can make any type callable. + +### `init` — constructor for `new` + +When `init` is defined, `{kind: 'new', type: T, value: }` parses +`` against `init.args` and runs `init.run` with `{this, args}` +in scope — `this` is a default-constructed value and `args` is the +parsed input. The expression returns either a fresh value (if the run +returns one) or the mutated `this`. Without `init`, `new T(value)` +just runs `T.parse(value)` directly. `duration` and `color` ship with +init defined; the LLM authors `new color({r: 255, g: 0, b: 0})` and +the constructor packs the channels into a 32-bit integer. + +The `value` slot of a `new` expression automatically reflects +`init.args` in the LLM-facing schema — devs don't write per-type +`toNewSchema` overrides for that case. + +--- + +## Generics -const r = createRegistry(); -const engine = createEngine(r); +A type can declare `generic` parameters — each entry's value is a +**constraint**, not a default. Bare `{name: 'R'}` inside the +signature is an unresolved placeholder (gin's `AliasType`); concrete +resolution happens when a call site supplies a binding. -const program = { - kind: 'define', - vars: [ - { name: 'x', value: { kind: 'new', type: { name: 'num' }, value: 2 } }, - ], - body: { - kind: 'get', - path: [ - { prop: 'x' }, // read x from scope - { prop: 'add' }, // num's `.add` method - { args: { other: { kind: 'new', type: { name: 'num' }, value: 3 } } }, - ], - }, -}; +- `R: any` — no constraint. Any type accepted as a binding. +- `R: text | obj` — bindings must be assignable to `text | obj`. + Anything else is rejected at the call site with a clear error. +- `R: ` — structural constraint. Bindings must satisfy + the interface (every prop / get / call the interface declares + exists on the binding with a compatible type). +- `R: alias('R')` — self-reference. Equivalent to "no constraint"; + the satisfies check is skipped. -const result = await engine.run(program); -console.log(result.raw); // 5 -console.log(result.type.name); // 'num' -``` +Bindings are validated when a `CallStep` provides them. There is no +implicit default — if you don't bind, the parameter stays a +placeholder and downstream type checks against it are permissive. -### 2. Extension types + lambdas + collection methods +Generics show up natively in fn types (`(args): R`), in +parameterized types (`list`, `map`, `optional`), and on +methods that introduce their own type parameters (`list.map(fn): list`). -Count completed tasks in a typed `list`: +--- -```ts -import { createRegistry, createEngine } from '@aeye/gin'; +## Type compatibility -const r = createRegistry(); +`a.compatible(b)` answers "every value of b is also a valid value of +a" — i.e. `b` is assignable to `a`. Used by: -// Declare Task as an obj extension with two typed fields. -const Task = r.extend(r.obj({ - title: { type: r.text({ minLength: 1 }) }, - done: { type: r.bool() }, -}), { name: 'Task', docs: 'An action item in a to-do list' }); -r.register(Task); +- **path validation** — a method call's args must be compatible with + the called fn's args type. +- **structural interface satisfaction** — does this object have all + the props an interface requires? +- **edit safety** — can this new type definition replace the old one + without breaking callers? Check both directions. -const engine = createEngine(r); +For obj specifically: `a.compatible(b)` requires every required +field of `a` to exist on `b` with a compatible per-field type. +Optional fields on `a` may be absent from `b` (the missing field +defaults to undefined, which optional accepts). Extra fields on `b` +are ignored. `opts.exact` tightens this to exact field-set match. -// tasks.filter(t => t.done).length -const program = { - kind: 'define', - vars: [{ - name: 'tasks', - value: { - kind: 'new', - type: { name: 'list', generic: { V: { name: 'Task' } } }, - value: [ - { title: 'ship it', done: true }, - { title: 'write docs', done: false }, - { title: 'deploy', done: true }, - ], - }, - }], - body: { - kind: 'get', - path: [ - { prop: 'tasks' }, - { prop: 'filter' }, - { - args: { - fn: { - kind: 'lambda', - type: { - name: 'function', - call: { args: { name: 'object' }, returns: { name: 'bool' } }, - }, - body: { - // `args.value` is the current Task (filter passes {value, index}). - kind: 'get', - path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'done' }], - }, - }, - }, - }, - { prop: 'length' }, - ], - }, -}; +For fn: bivariant on args (matches TypeScript's default method-arg +rule), covariant on returns. Most code wants the bivariant form; +edit-compat tooling splits args + returns and checks each side +directionally to enforce strict TS-style variance. -const result = await engine.run(program); -console.log(result.raw); // 2 -``` +--- -Everything above round-trips through `JSON.stringify`/`JSON.parse` — -the program, the Task type, every intermediate value. An LLM can -produce the same shape directly. +## Extensions -### 3. Native functions +`registry.extend(base, { name, ... })` creates a named type that +overlays additions on a base. Extensions can: -Hook any JS/TS function into gin's call system by id: +- **Add props** — new fields and methods. +- **Override `get` / `call` / `init`** — replace any of the base's + surfaces. +- **Narrow options** — `Email` extending `text({pattern: ...})` + carries the tighter pattern at runtime. +- **Add a constraint Expr** — a runtime predicate every value must + satisfy. Evaluated on `engine.validateValue(v)`; runs with `this` + bound to the value. +- **Declare `generic`** — extensions can have their own type params. -```ts -import { val } from '@aeye/gin'; +Extensions delegate everything to the base via `Type.compatible`, +`Type.props` composition, etc. `Email extends text` is a real +subtype: every Email is a valid text; tighter tests pass on +Email-only values. -// Override `num.sqrt` so it does the obvious thing. -r.setNative('num.sqrt', (scope, registry) => - val(registry.num(), Math.sqrt((scope.get('this')!.raw as number))), -); +--- -const sqrt16 = { - kind: 'get', - path: [ - { prop: 'n' }, - { prop: 'sqrt' }, - { args: {} }, - ], -}; -const result = await engine.run(sqrt16, { n: val(r.num(), 16) }); -console.log(result.raw); // 4 -``` +## Augmentations -## Core concepts +`registry.augment(name, { props?, get?, call?, init? })` adds to an +EXISTING type by name — works for built-ins (`'num'`, `'text'`, +`'date'`, `'timestamp'`, ...) and registered named types. Augmentation +is gentler than extension: -### `Type` +- `props` are MERGED into the type's existing props. Intrinsic names + win on conflict — you can't override `num.add` by augmenting num. +- `get` / `call` / `init` are applied IFF the type has none of its + own. Augmentation FILLS GAPS — give `date` a `get` so it iterates, + make `timestamp` callable, give `text` a constructor — but never + overrides what's already there. -A `Type` describes the shape of values and exposes the operations on -them. Every type implements: +The augmented surface flows through every consumer: path-walks +dispatch against augmented props; static analysis sees them; code +rendering shows them. No subclassing or wrapper required. -| Method | Purpose | -|---|---| -| `valid(raw)` | Runtime type guard over the raw value | -| `parse(json)` | JSON → `Value` (throws on mismatch) | -| `encode(raw)` | raw → JSON envelope (round-trip-safe) | -| `compatible(other)` | structural compatibility check | -| `like(other)` | narrow self by `other`, recursing through children | -| `bind(bindings)` | substitute generic placeholders | -| `props()` / `get()` / `call()` / `init()` | expose fields, index access, call signatures, constructors | -| `toCode()` / `toCodeDefinition()` | render TypeScript-like source for the LLM | -| `toSchema(opts)` | Zod schema for the TypeDef JSON | -| `toValueSchema(opts)` | Zod schema for the runtime VALUE | -| `toNewSchema(opts)` | Zod schema for the value side of `{kind:'new'}` | -| `toInstanceSchema()` | Zod schema that narrow-matches TypeDef JSON (used by `typ`) | -| `toJSON()` | serialize the Type itself to a TypeDef | - -### `Value` - -A `Value` pairs a type with a runtime raw payload: +When you want to genuinely REPLACE behavior (not just add), use an +Extension — extensions own their entire surface and can override +freely. -```ts -class Value { - readonly type: Type; - readonly raw: RuntimeOf; - toJSON(): JSONValue; // { type: TypeDef, value: JSONOf } -} -``` +--- -Composites store `Value`-wrapped children so per-element concrete types -survive JSON round-trips — a `Dog` stored in a `list` comes -back as a `Dog`, not widened to `Animal`. +## The 12 expression kinds -### `Expr` +A gin program is a tree of `Expr` JSON objects. Every node has +`kind: '...'` plus the fields that kind declares. -The expression AST. Every node has a `kind` and serializes to -`ExprDef`. The built-in kinds: +### `new` — construct a value of a given type -| Kind | Purpose | -|---|---| -| `new` | Construct a value of a specific type | -| `get` | Read a path (`{prop}`, `{args}`, `{key}`) from scope | -| `set` | Write a path target | -| `define` | Bind local variables in a child scope | -| `block` | Sequence expressions; last value wins | -| `if` | Multi-arm conditional with optional else | -| `switch` | Value discrimination with `equals` patterns | -| `loop` | Body + condition + end/step (supports `break`/`continue`) | -| `lambda` | Inline function value | -| `template` | Handlebars-powered string interpolation | -| `flow` | `return` / `break` / `continue` / `throw` signals | -| `native` | Direct call into a registered native implementation | - -### `Registry` - -Central authority: - -1. Maps `name → Type class` for JSON parse dispatch. -2. Maps `name → Type instance` for user-registered named types. -3. Maps `id → NativeImpl` for native-function overrides. -4. Implements `TypeBuilder` — the factory for constructing types - (`r.num()`, `r.list(r.text())`, `r.fn(args, returns)`, ...). +`{ kind: 'new', type: , value?: }` -```ts -const r = createRegistry(); -r.register(r.extend(r.num(), { name: 'Positive', constraint: /* ... */ })); -r.setNative('my.op', (scope, registry) => val(registry.text(), 'ok')); -``` +If the type has `init`, `value` is parsed as `init.args` and the +constructor runs. Otherwise `value` is parsed as `type` directly. With +no `value`, returns `Value(type, type.create())` — the type's default. -### `Engine` +### `get` — read through a path -Stateless across runs. Each `run()` builds a fresh root scope seeded -with registered globals plus per-call extras: +`{ kind: 'get', path: [, , ...] }` -```ts -const engine = createEngine(r); -engine.registerGlobal('PI', { type: r.num(), value: 3.14 }); -const result = await engine.run(expr, { userInput: val(r.text(), 'hello') }); -``` +Steps walk left-to-right. Each step is `{prop: 'name'}` (named +access), `{args: {...}}` (call the previous step — used after a +method or any callable), or `{key: }` (indexed access). The +first step is always `{prop: ''}`. Result is the final +step's value. -Also exposes `engine.typeOf(expr)` (static type inference) and -`engine.validate(expr)` (structural problem collection) for tooling -that wants to analyze a program without running it. +### `set` — write through a path -## Type system +`{ kind: 'set', path: [, ...], value: }` -### Leaves +Same path grammar as `get`, but the tail step writes. Returns `bool`: +true on success, false if a safe-nav null/undefined short-circuited +the walk. -| Type | Options | -|---|---| -| `any` | top type — accepts anything | -| `void` / `null` | bottom-ish unit types | -| `bool` | `{}` | -| `num` | `min`, `max`, `whole`, `minPrecision`, `maxPrecision`, `prefix`, `suffix` | -| `text` | `minLength`, `maxLength`, `pattern`, `flags` | -| `date` / `timestamp` | `min`, `max`, `utc`, (timestamp) `precision` | -| `duration` | milliseconds | -| `color` | `hasAlpha` | -| `literal` | exact-value constraint over inner type | - -All leaves enforce their options at `parse()` time and carry them -through to `toValueSchema()`. - -### Containers - -| Type | Description | +### `define` — bind locals into a child scope + +`{ kind: 'define', vars: [{ name, type?, value }, ...], body: }` + +Each var is added to scope BEFORE the next var's value is evaluated, +so later vars can reference earlier ones. The body runs with all +vars in scope; its result is the define's value. + +### `block` — sequence of expressions + +`{ kind: 'block', lines: [, ...] }` + +Lines run in order. Earlier lines are evaluated for their side +effects (set, native calls, fns); the block's value is the LAST +line's value. An empty block returns void. + +### `if` — conditional branching + +`{ kind: 'if', ifs: [{ condition, body }, ...], else?: }` + +Each condition must be `bool`-typed. First branch whose condition is +true wins. Without an else, a no-match if-expression returns void. + +### `switch` — value-based branching + +`{ kind: 'switch', value: , cases: [{ equals: [...], body }], else?: }` + +The case wins if `value` equals ANY one of `equals`. Cases are NOT +fall-through; only the matching case's body runs. + +### `loop` — iterate any iterable + +`{ kind: 'loop', over: , body: , key?: string, value?: string, parallel?: {...} }` + +Two evaluation modes by `over`'s static type: +- **Iterable** (`get().loop` defined): walked once. `key` / `value` + bind to scope under those names (override defaults via the optional + fields). +- **Bool while-loop** (`get().loopDynamic === true`): `over` is + RE-EVALUATED each iteration. The loop continues while truthy and + exits the moment it becomes false. `bool` uses this. + +Optional `parallel: { concurrent?, rate? }` fans body execution out: +`concurrent` caps simultaneous bodies, `rate` paces start times. The +native iterator just calls `yield(k, v)`; the parallel orchestration +sits in `LoopExpr.evaluate` so every iterable inherits it for free. + +Parallel composes with the dynamic mode too: `bool over` plus +`parallel: { concurrent: 3 }` fans the body out up to 3 in-flight, +and `over` is re-evaluated against the outer scope every time a task +COMPLETES (not when it starts). So accumulating side effects from +the prior batch decide whether more tasks spawn. + +### `lambda` — callable closure over the lexical scope + +`{ kind: 'lambda', type: , body: , constraint?: }` + +Inside the body, `args` is the call-site arguments obj and `recurse` +is this lambda (for self-calls). Optional `constraint` runs before +the body each call (must return `bool`); throws on false. + +### `template` — string interpolation + +`{ kind: 'template', template: '', params: }` + +Each `{name}` placeholder in the string is replaced with the +stringified `params.name`. Compiles to a JS template literal in +`toCode` rendering when params is a `new obj` literal. + +### `flow` — non-local control flow + +`{ kind: 'flow', action: 'break' | 'continue' | 'return' | 'exit' | 'throw', value?, error? }` + +- `break` / `continue` — only valid inside a `loop`. +- `return` — unwinds to the enclosing lambda or fn body; `value` + becomes the result. +- `exit` — unwinds all the way to `engine.run`; `value` becomes the + program result. +- `throw` — raises `error`; caught by a path step's `catch:` handler. + +### `native` — escape hatch to a registered native impl + +`{ kind: 'native', id: '', type?: }` + +Calls into a JS/TS function registered via `registry.setNative(id, +impl)`. Most natives are referenced indirectly — `num.add`'s prop +type carries `{kind: 'native', id: 'num.add'}` as its get expression, +so a path call to `.add` dispatches without any explicit `native` +node in user code. You'd hand-write a `native` node when authoring a +custom loop ExprDef or a method whose impl lives outside gin. + +--- + +## Parsing + +gin has TWO levels of parsing — they compose: + +1. **JSON → runtime objects.** `registry.parse(typeDef)` turns a + `TypeDef` JSON into a `Type` instance; `registry.parseExpr(exprDef, + scope?)` turns an `ExprDef` into an `Expr`. Inverse: + `type.toJSON()` / `expr.toJSON()`. Round-trips losslessly. + +2. **Runtime data → typed values.** Once you have a `Type`, calling + `type.parse(jsonData)` validates the data and returns a `Value` + — the runtime currency. A `Value` is a `{type, raw}` pair where + `raw` is the JS storage shape. `value.toJSON()` produces the JSON + shape; `type.encode(value.raw)` does the same at the type level. + +Both levels are scope-aware. Generic placeholders (`AliasType`) +resolve through the scope passed to parse — that's how a `CallStep`'s +`generic: { R: }` map flows into the called signature without +rebuilding the type tree. + +--- + +## The Registry — the only class you really need + +`Registry` is your interface. Every other class (`Type`, `Expr`, +`Engine`, `Value`, `Path`, ...) is reachable through it. You'll rarely +construct one yourself — `createRegistry()` ships with every built-in +type, native, and Expr class pre-registered. + +Key methods: + +| Method | Purpose | |---|---| -| `list` | ordered collection; `minLength`/`maxLength` | -| `map` | typed entry list — LLM-friendly shape `[{key,value}]` | -| `tuple` | fixed-arity positional | -| `obj{prop: Type, ...}` | structural record with declared fields | -| `optional` | `T ∣ undefined` | -| `nullable` | `T ∣ null` | -| `fn` | callable with obj args, return R, optional throws E | -| `iface{props, get, call}` | contract a value must satisfy structurally | -| `enum` | constrained set of values | -| `or` / `and` / `not` | type algebra | -| `ref` | lazy reference to a registered named type (enables recursion) | -| `generic` | type-parameter placeholder | -| `typ` | values ARE Types; T constrains which Types are acceptable | - -### Extensions +| `parse(def)` / `parseExpr(def, scope?)` | TypeDef / ExprDef → runtime | +| `define(cls)` | Register a built-in Type class for JSON dispatch | +| `register(type)` | Register a named Type instance (typically an Extension) | +| `lookup(name)` | Look up a Type by name (registered → built-in fallback) | +| `setNative(id, impl)` | Wire a JS function as a gin native | +| `getNative(id)` | Read it back | +| `defineExpr(cls)` | Register an ExprClass (12 ship; you rarely add more) | +| `extend(base, { name, ... })` | Create a named Extension | +| `augment(name, { props?, get?, call?, init? })` | Add to an existing type by name | +| `augmentation(name)` | Read augmentation back | +| `like(type)` | Pick a registered concrete type compatible with a constraint | + +The builder methods (`r.num()`, `r.text()`, `r.list(item)`, `r.obj({...})`, +`r.fn(args, returns, throws?, generic?)`, `r.iface({...})`, +`r.method(args, returns, nativeId)`, `r.prop(type, nativeId)`, ...) are +sugar for parse — they construct runtime types without going through +JSON. + +`createEngine(registry)` builds an Engine that owns evaluation, +validation, and type-inference walks. Programs run via `engine.run(expr, +extras?)`; static analysis via `engine.validate(expr)` / +`engine.typeOf(expr)`. + +--- + +## Built-in type catalog + +Below is what `createRegistry()` ships with — the surface every gin +program starts with. Each type's section is the same `toCodeDefinition` +output an LLM sees in its prompt. -```ts -const Task = r.extend(r.obj({ - title: { type: r.text({ minLength: 1 }) }, - done: { type: r.bool() }, -}), { - name: 'Task', - docs: 'An action item in a to-do list', - props: { - isOverdue: r.method({}, r.bool(), 'task.isOverdue'), - }, -}); -r.register(Task); ``` +type any { + toAny(): any + typeOf(): text + is(): bool + as(): optional + toText(): text + toBool(): bool + eq(other: any): bool + neq(other: any): bool +} -An `Extension` wraps a base type, adds local options / fields / methods -/ constraint predicates, and preserves structural compatibility with -the base. Multi-level extension is supported — every layer's props -compose. +type void { + toAny(): any + toText(): text + toBool(): bool +} -### Recursive types +type null { + toAny(): any + toText(): text + toBool(): bool +} -`r.ref(name)` returns a lazy reference. The target doesn't need to -exist at construction time — resolve happens at use time, so mutual -cycles work: +type bool { + [key: num{whole=true, min=0}]: bool + toAny(): any + eq(other: bool): bool + neq(other: bool): bool + and(other: bool): bool + or(other: bool): bool + xor(other: bool): bool + not(): bool + toText(): text + toNum(): num +} -```ts -const Task = r.extend(r.obj({ - title: { type: r.text() }, - creator: { type: r.ref('User') }, -}), { name: 'Task' }); -r.register(Task); - -const User = r.extend(r.obj({ - name: { type: r.text() }, - tasks: { type: r.list(r.ref('Task')) }, -}), { name: 'User' }); -r.register(User); -``` +type num { + [key: num{whole=true, min=0}]: num + toAny(): any + eq(other: num, epsilon?: num): bool + neq(other: num, epsilon?: num): bool + lt(other: num): bool + lte(other: num): bool + gt(other: num): bool + gte(other: num): bool + add(other: num): num + sub(other: num): num + mul(other: num): num + div(other: num): num + mod(other: num): num + pow(other: num): num + abs(): num + neg(): num + sign(): num + sqrt(): num + min(other: num): num + max(other: num): num + clamp(min: num, max: num): num + floor(): num + ceil(): num + round(): num + isZero(): bool + isPositive(): bool + isNegative(): bool + isInteger(): bool + isEven(): bool + isOdd(): bool + toText(precision?: num): text + toBool(): bool +} -### Generics +type text { + [key: num]: text{minLength=1, maxLength=1} + toAny(): any + length: num + eq(other: text): bool + neq(other: text): bool + contains(search: text): bool + startsWith(prefix: text): bool + endsWith(suffix: text): bool + trim(): text + trimStart(): text + trimEnd(): text + upper(): text + lower(): text + slice(start: num, end?: num): text + replace(search: text, replacement: text): text + split(separator: text): list + concat(other: text): text + repeat(count: num): text + indexOf(search: text, from?: num): num + lastIndexOf(search: text, from?: num): num + match(pattern: text): list + test(pattern: text): bool + isEmpty(): bool + isNotEmpty(): bool + toNum(): num + toBool(): bool +} -Type-parameterized types (`list`, `map`, `typ`) store their -parameters in a `generic: Record` map on the base class. -`bind(bindings)` substitutes them through the whole subtree. Generic -placeholders pre-binding are maximally permissive. +type list { + [key: num{whole=true, min=0}]: V + length: num + at(index: num): optional + push(value: V): void + pop(): optional + shift(): optional + unshift(value: V): void + insert(index: num, value: V): void + remove(index: num): V + clear(): void + slice(start?: num, end?: num): list + concat(other: list): list + reverse(): list + join(separator?: text): text + indexOf(value: V): num + contains(value: V): bool + unique(): list + duplicates(): list + map(fn: (value: V, index: num): R): list + filter(fn: (value: V, index: num): bool): list + find(fn: (value: V, index: num): bool): optional + reduce(fn: (acc: R, value: V, index: num): R, initial: R): R + some(fn: (value: V, index: num): bool): bool + every(fn: (value: V, index: num): bool): bool + sort(fn?: (a: V, b: V): num): list + isEmpty(): bool + isNotEmpty(): bool + first?: V + last?: V +} -Function types support **method-level generics**: +type map { + [key: K]: V + size: num + at(key: K): optional + has(key: K): bool + delete(key: K): bool + clear(): void + keys(): list + values(): list + isEmpty(): bool + isNotEmpty(): bool +} -```ts -// list.map(fn: (value:V, index:num) => R): list -const listT = r.list(r.generic('V')); -listT.toCodeDefinition(); -// type list { -// map(fn: (value: V, index: num): R): list -// ... -// } -``` +type tuple<...elements> { + [key: num]: + length: num + first: + last: + toList(): list +} -### `typ` — types-as-values +type obj { + keys(): list + values(): list + entries(): list> + has(key: text): bool + eq(other: any): bool + neq(other: any): bool + toText(): text +} -Sometimes you want a program to receive a *type* as an argument — e.g. -"parse this HTTP response as `T`". `typ` does that: +type optional { + value: T + has(): bool + or(fallback: T): T + map(fn: (value: T): R): optional +} -```ts -// fn fetch(args: { url: text, output?: typ }): R -const fetchFn = r.fn( - r.obj({ - url: { type: r.text() }, - output: { type: r.optional(r.typ(r.generic('R'))) }, - }), - r.generic('R'), - undefined, - { R: r.text() }, -); -``` +type nullable { + value: T + isNull(): bool + or(fallback: T): T + map(fn: (value: T): R): nullable +} -`typ`'s runtime `.raw` is a `Type` instance (one-shot parsed from -TypeDef JSON). Its `toValueSchema()` emits a Zod union of every -registry type compatible with `num` — `{name:'num'}`, `{name:'Positive'}` -(if registered), etc. — plus an inline-Extension branch whose `extends` -enum is narrowed to compatible bases. The LLM sees exactly the valid -choices. +type or<...variants> // union; props/get/call when ALL variants share them +type and<...parts> // intersection; props from ANY part +type not // any value EXCEPT one matching excluded +type literal // one specific constant value of T +type enum // named constants of value type V +type function // see "call" — args/returns/throws/generic +type interface // structural contract; props/get/call only +type typ // a value that IS a Type, constrained by T +type alias // bare-name reference / generic placeholder + +type date { + year, month, day, dayOfWeek, dayOfYear // num + eq, neq, before, after // (other: date) → bool + addDays/Months/Years, diffDays/Months/Years + toText(format?): text +} -## Schema layers +type timestamp { + year..millisecond // num + eq, before, after // (other: timestamp) → bool + addDuration, subDuration, diff + toDate(): date + toEpoch(): num + toText(format?): text +} -gin produces four distinct Zod schemas, each for a different purpose: +type duration { + new(days?, hours?, minutes?, seconds?, ms?) + totalSeconds, totalMinutes, totalHours, totalDays + days, hours, minutes, seconds, ms + toText(format?): text +} -| Method | What it validates | -|---|---| -| `static TypeClass.toSchema(opts)` | The TypeDef JSON shape for this class. Used by `buildSchemas` to union every registered type. | -| `type.toValueSchema(opts)` | A runtime value of this type (a number for `num`, `{x,y}` for an obj). Feeds LLM structured-output modes. | -| `type.toNewSchema(opts)` | The `value:` side of a `{kind:'new'}` expression. For composites, each slot is `opts.Expr` (any expression). | -| `type.toInstanceSchema()` | Narrow-match against this specific instance's TypeDef JSON. Used by `typ` to emit the compatible-types union. | +type color { + new(r, g, b, a?) + r, g, b, a, hue, saturation, lightness // num + eq, neq // (other: color) → bool + lighten, darken, saturate, desaturate, opacity, invert, mix, complement + toHex, toRgb, toHsl, toText // → text +} +``` + +--- -`buildSchemas(registry, overrides?)` composes the recursive -`opts.Type` / `opts.Expr` schemas the LLM uses to author programs. Pass -`{ newStrict: true }` to get a discriminated union per registered type -instead of the loose class-level fallback. +## Putting it together -## Native functions +A single example demonstrating the four developer-facing surfaces: ```ts -r.setNative('num.add', (scope, registry) => { - const self = scope.get('this')!.raw as number; - const other = (scope.get('args')!.raw as any).other.raw as number; - return val(registry.num(), self + other); +import { createRegistry, createEngine, GetSet, Init, val, Value } from '@aeye/gin'; + +const r = createRegistry(); + +// 1. Extension — a real subtype with its own surface. +const Email = r.extend( + r.text({ pattern: '^[^@]+@[^@]+$', minLength: 3 }), + { + name: 'Email', + docs: 'A text value matching a basic email shape', + props: { + domain: r.method({}, r.text(), 'Email.domain'), + }, + }, +); +r.register(Email); + +// 2. Native — the JS implementation of Email.domain. Natives access +// `this` via scope.get('this'). +r.setNative('Email.domain', (scope, reg) => { + const self = scope.get('this')!.raw as string; + return val(reg.text(), self.split('@')[1] ?? ''); }); -``` -Built-in natives for every leaf/container method are registered by -`registerBuiltinNatives(registry)`. User code can override any of them -by id to inject instrumentation or swap implementations. +// 3. Augmentation — give the existing `num` type a `clamp01` method, +// AND a constructor so `new num({percent})` produces a 0–1 num. +r.augment('num', { + props: { + clamp01: r.method({}, r.num({ min: 0, max: 1 }), 'num.clamp01'), + }, + init: new Init({ + args: r.obj({ percent: { type: r.num({ min: 0, max: 100 }) } }) as any, + run: { kind: 'native', id: 'num.fromPercent' }, + }), +}); -## Analysis without running +r.setNative('num.clamp01', (scope, reg) => { + const n = scope.get('this')!.raw as number; + return val(reg.num({ min: 0, max: 1 }), Math.max(0, Math.min(1, n))); +}); +r.setNative('num.fromPercent', (scope, reg) => { + const args = scope.get('args')!.raw as Record; + const pct = args['percent']!.raw as number; + return val(reg.num({ min: 0, max: 1 }), pct / 100); +}); -- `engine.typeOf(expr)` returns the inferred `Type` of an expression - under a given `TypeScope`. Never throws — unknowns fall through to `any`. -- `engine.validate(expr)` walks the AST collecting `Problems` — unknown - vars, unknown natives, out-of-place `break`/`return`, etc. Useful - for warning the LLM before wasting a full run. +// 4. Run a program. (Programs are JSON — typically authored by an LLM, +// not hand-written. Here we hand-write one for illustration.) +const engine = createEngine(r); -## Testing +const program = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [ + // `new num({percent: 75})` — augmented init runs; result is 0.75. + { name: 'opacity', value: { + kind: 'new', + type: { name: 'num' }, + value: { percent: 75 }, + } }, + { name: 'address', value: { + kind: 'new', + type: { name: 'Email' }, + value: 'team@example.com', + } }, + ], + body: { + kind: 'block', + lines: [ + // Augmented method: opacity.clamp01() — already in [0,1]. + { kind: 'get', path: [{ prop: 'opacity' }, { prop: 'clamp01' }, { args: {} }] }, + // Extension method: address.domain() → 'example.com'. + { kind: 'get', path: [{ prop: 'address' }, { prop: 'domain' }, { args: {} }] }, + ], + }, + }, + ], +}; -```bash -npm test # 615+ tests covering every type, expression, and edge -npm run dump-schema # emit a sample opts.Type/opts.Expr union -npm run dump-code # emit toCodeDefinition() for every built-in +const result = await engine.run(program); +console.log(result.raw); // 'example.com' ``` -## Use cases - -- **Typed tool outputs for LLM agents.** Have the LLM produce an - ExprDef the agent can statically validate, execute, and trust the - return shape of. -- **Runtime-authored programs.** Let users (or models) define - pipelines, transformations, or DSLs without shipping a parser. -- **Structured-output schema generation.** Produce Zod schemas from - typed user-input definitions and pass them straight to - `ai.chat.get({responseFormat})`-style APIs. -- **Cross-session persistence.** Every Type and Expr is JSON — write - it to disk, load it later, execute against the same registry. -- **Sandboxed execution.** Programs only see what you registered as - natives and globals; no filesystem, no network unless you wire it. - -## Related packages - -- **[`@aeye/ginny`](../ginny)** — a CLI that turns natural-language - requests into executable gin programs. Uses the type system and - expression engine described here as its runtime. +What this exercises: + +- `r.extend(...)` produces `Email`, a real subtype of `text` with a + custom prop. Static analysis treats Email as text everywhere text + is expected. +- `r.augment('num', ...)` adds `clamp01` AND `init` to the canonical + `num` type. Every num — including extensions over num — picks them + up. `new num({percent: 75})` flows through the augmented init. +- `r.setNative(id, impl)` wires the JS implementations. Any path call + that references those native ids dispatches through them. +- `engine.run(program)` evaluates the JSON tree, validating types as + it walks. + +Augmentations and extensions live on the registry. Pass that registry +to the engine — and to any prompt schema generator (`buildSchemas(r)`) +— so the LLM authoring programs sees the full surface. + +--- ## License diff --git a/packages/gin/package.json b/packages/gin/package.json index be476414..f1c9ffe0 100644 --- a/packages/gin/package.json +++ b/packages/gin/package.json @@ -1,6 +1,6 @@ { "name": "@aeye/gin", - "version": "0.1.0", + "version": "0.3.8", "description": "Gin - A type & expression system for LLM agents", "type": "module", "main": "dist/index.js", diff --git a/packages/gin/src/__tests__/binding-rules.test.ts b/packages/gin/src/__tests__/binding-rules.test.ts new file mode 100644 index 00000000..56b8bdb1 --- /dev/null +++ b/packages/gin/src/__tests__/binding-rules.test.ts @@ -0,0 +1,248 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, RESERVED_NAMES, checkBindingName, Problems } from '../index'; +import type { Locals } from '../analysis'; + +/** + * Tests for the user-binding hygiene rules added in `analysis.ts` + * `checkBindingName` and consumed by `DefineExpr.validateWalk` / + * `LoopExpr.validateWalk`. The rules are: + * + * - User-supplied binding names cannot be reserved (gin's runtime + * binds those — `args`, `recurse`, `this`, `super`, `key`, `value`, + * `yield`, `error`). + * - User-supplied binding names cannot already exist in scope — + * including outer-scope vars and globals. + */ + +const e = new Engine(createRegistry()); + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; + +const numType = { name: 'num' } as const; + +describe('RESERVED_NAMES set', () => { + test('contains every name gin runtime injects', () => { + for (const n of ['args', 'recurse', 'this', 'super', 'key', 'value', 'yield', 'error']) { + expect(RESERVED_NAMES.has(n)).toBe(true); + } + }); + + test('does not include arbitrary user names', () => { + expect(RESERVED_NAMES.has('foo')).toBe(false); + expect(RESERVED_NAMES.has('result')).toBe(false); + }); +}); + +describe('checkBindingName helper', () => { + test('reserved name → binding.reserved error', () => { + const p = new Problems(); + const scope: Locals = new Map(); + checkBindingName('args', scope, p); + expect(p.list).toHaveLength(1); + expect(p.list[0]!.code).toBe('binding.reserved'); + expect(p.list[0]!.severity).toBe('error'); + }); + + test('name in scope → binding.shadow error', () => { + const p = new Problems(); + const scope: Locals = new Map(); + scope.set('foo', e.registry.num()); + checkBindingName('foo', scope, p); + expect(p.list).toHaveLength(1); + expect(p.list[0]!.code).toBe('binding.shadow'); + expect(p.list[0]!.severity).toBe('error'); + }); + + test('reserved name takes precedence over shadow check', () => { + // A name that is BOTH reserved AND in scope reports as reserved + // (clearer message; the helper returns after the reserved branch). + const p = new Problems(); + const scope: Locals = new Map(); + scope.set('args', e.registry.any()); + checkBindingName('args', scope, p); + expect(p.list).toHaveLength(1); + expect(p.list[0]!.code).toBe('binding.reserved'); + }); + + test('fresh non-reserved name → no error', () => { + const p = new Problems(); + const scope: Locals = new Map(); + checkBindingName('myVar', scope, p); + expect(p.list).toHaveLength(0); + }); +}); + +describe('DefineExpr — reserved-name rule', () => { + for (const reserved of ['args', 'recurse', 'this', 'super', 'key', 'value', 'yield', 'error']) { + test(`define '${reserved}' → binding.reserved`, () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: reserved, type: numType, value: numLit(1) }], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code === 'binding.reserved')).toBe(true); + }); + } + + test('reserved-name error path includes vars[i].name', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'args', type: numType, value: numLit(1) }], + body: numLit(0), + }); + const err = probs.list.find((p) => p.code === 'binding.reserved'); + expect(err).toBeDefined(); + expect(err!.path).toEqual(['vars', 0, 'name']); + }); + + test('non-reserved name → no binding error', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'myCounter', type: numType, value: numLit(1) }], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code.startsWith('binding.'))).toBe(false); + }); +}); + +describe('DefineExpr — shadow rule', () => { + test('two vars in one define with the same name → second flags shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'x', type: numType, value: numLit(1) }, + { name: 'x', type: numType, value: numLit(2) }, + ], + body: numLit(0), + }); + const shadows = probs.list.filter((p) => p.code === 'binding.shadow'); + expect(shadows).toHaveLength(1); + expect(shadows[0]!.path).toEqual(['vars', 1, 'name']); + }); + + test('inner define shadowing outer define → flags shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', type: numType, value: numLit(1) }], + body: { + kind: 'define', + vars: [{ name: 'x', type: numType, value: numLit(2) }], + body: numLit(0), + }, + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(true); + }); + + test('define shadowing a global → flags shadow', () => { + // Register a global so its name is part of the engine's type scope. + const r = createRegistry(); + const eng = new Engine(r); + eng.registerGlobal('myGlobal', { type: r.num(), value: 42 }); + const probs = eng.validate({ + kind: 'define', + vars: [{ name: 'myGlobal', type: numType, value: numLit(1) }], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(true); + }); + + test('sibling defines with distinct names → no shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'a', type: numType, value: numLit(1) }, + { name: 'b', type: numType, value: numLit(2) }, + ], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(false); + }); +}); + +describe('DefineExpr — runtime still works for valid bindings', () => { + test('valid define evaluates to the body result', async () => { + const v = await e.run({ + kind: 'define', + vars: [{ name: 'x', type: numType, value: numLit(7) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(v.raw).toBe(7); + }); +}); + +describe('LoopExpr — overrides honor binding rules', () => { + // Build a list over expression so the loop's `over` typechecks. + const overList = { + kind: 'new', + type: { name: 'list', generic: { V: { name: 'num' } } }, + value: [ + { kind: 'new', type: { name: 'num' }, value: 10 }, + { kind: 'new', type: { name: 'num' }, value: 20 }, + ], + } as const; + + test('keyName override to a reserved name → binding.reserved at path "key"', () => { + const probs = e.validate({ + kind: 'loop', + over: overList, + key: 'args', + body: { kind: 'block', lines: [] }, + }); + const err = probs.list.find((p) => p.code === 'binding.reserved'); + expect(err).toBeDefined(); + expect(err!.path).toEqual(['key']); + }); + + test('valueName override to a reserved name → binding.reserved at path "value"', () => { + const probs = e.validate({ + kind: 'loop', + over: overList, + value: 'recurse', + body: { kind: 'block', lines: [] }, + }); + const err = probs.list.find((p) => p.code === 'binding.reserved'); + expect(err).toBeDefined(); + expect(err!.path).toEqual(['value']); + }); + + test('keyName override that shadows an outer binding → binding.shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'taken', type: numType, value: numLit(0) }], + body: { + kind: 'loop', + over: overList, + key: 'taken', + body: { kind: 'block', lines: [] }, + }, + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(true); + }); + + test('default key/value (no override) → no binding error even if `key` exists in outer scope', () => { + // Defaults are reserved precisely because loops bind them. Nested + // loops are expected to shadow `key`/`value`; we only validate the + // explicit overrides. + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'someName', type: numType, value: numLit(0) }], + body: { + kind: 'loop', + over: overList, + body: { kind: 'block', lines: [] }, + }, + }); + expect(probs.list.some((p) => p.code.startsWith('binding.'))).toBe(false); + }); + + test('valid keyName/valueName override → no error', () => { + const probs = e.validate({ + kind: 'loop', + over: overList, + key: 'idx', + value: 'item', + body: { kind: 'block', lines: [] }, + }); + expect(probs.list.some((p) => p.code.startsWith('binding.'))).toBe(false); + }); +}); diff --git a/packages/gin/src/__tests__/build-schemas-named.test.ts b/packages/gin/src/__tests__/build-schemas-named.test.ts index f9f45fb3..f611984e 100644 --- a/packages/gin/src/__tests__/build-schemas-named.test.ts +++ b/packages/gin/src/__tests__/build-schemas-named.test.ts @@ -9,7 +9,7 @@ import { createRegistry, buildSchemas } from '../index'; describe('buildSchemas: named user types', () => { test('registered Extension shows up in opts.Type', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', props: { title: { type: r.text({ minLength: 1 }) }, @@ -32,14 +32,14 @@ describe('buildSchemas: named user types', () => { const r = createRegistry(); // Register A with one shape. - const A1 = r.extend('object', { + const A1 = r.extend('obj', { name: 'A', props: { x: { type: r.num() } }, }); r.register(A1); // Pass a DIFFERENT A via opts.types — should win the dedup. - const A2 = r.extend('object', { + const A2 = r.extend('obj', { name: 'A', props: { y: { type: r.text() } }, }); @@ -57,7 +57,7 @@ describe('buildSchemas: named user types', () => { test('user-defined type is usable from an LLM-style JSON type reference', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', docs: 'a work item', props: { @@ -86,7 +86,7 @@ describe('buildSchemas: named user types', () => { test('unregistered extension is invisible to opts.Type', () => { const r = createRegistry(); // An Extension that's never registered — no branch for it. - r.extend('object', { + r.extend('obj', { name: 'OrphanTask', props: { title: { type: r.text() } }, }); @@ -95,7 +95,7 @@ describe('buildSchemas: named user types', () => { // (`num`, `object`, etc.), so ad-hoc extensions are not representable. expect(opts.Type.safeParse({ name: 'OrphanTask' }).success).toBe(false); // Register the same structure → now accepted. - const sibling = r.extend('object', { + const sibling = r.extend('obj', { name: 'RegisteredTask', props: { title: { type: r.text() } }, }); diff --git a/packages/gin/src/__tests__/call-type-aliases.test.ts b/packages/gin/src/__tests__/call-type-aliases.test.ts new file mode 100644 index 00000000..6064046d --- /dev/null +++ b/packages/gin/src/__tests__/call-type-aliases.test.ts @@ -0,0 +1,186 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, FnType, ListType, type TypeDef } from '../index'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; + +/** + * Call-level type aliases (`CallDef.types`) — declared aliases are + * bound into a `LocalScope` while the call's slots parse, so bare + * `{name: ''}` references inside `args` / `returns` / `throws` + * resolve to AliasType wrappers around the alias's parsed Type. + * + * Round-trip is symmetric: `toJSON` re-emits the alias map and the + * bare-name references; `parse` rebuilds the same structure. + */ + +const r = createRegistry(); +const e = new Engine(r); + +describe('CallDef.types — basic resolution', () => { + test('alias referenced twice in args resolves to the alias target', () => { + const fn = r.parse({ + name: 'fn', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'obj', props: { a: { type: { name: 'counter' } }, b: { type: { name: 'counter' } } } }, + returns: { name: 'counter' }, + }, + }); + // toCode resolves through AliasType.simplify-style behavior — both + // `a` and `b` show as the alias's resolved underlying type. + expect(fn.toCode()).toContain('a: counter'); + expect(fn.toCode()).toContain('b: counter'); + // The parsed args' value Type for `a` and `b` is an AliasType + // pointing to `counter`; resolved properties reflect the underlying + // num{whole, min:1}. + const fields = ((fn as FnType)._call.args as unknown as { fields: Record }).fields; + expect(fields.a!.type.valid(5)).toBe(true); + expect(fields.a!.type.valid(0)).toBe(false); + }); + + test('sequential aliases — later refs earlier', () => { + const fn = r.parse({ + name: 'fn', + call: { + types: { + A: { name: 'num', options: { whole: true, min: 1 } }, + B: { name: 'list', generic: { V: { name: 'A' } } }, + }, + args: { name: 'obj', props: { items: { type: { name: 'B' } } } }, + returns: { name: 'A' }, + }, + }); + const items = ((fn as FnType)._call.args as unknown as { fields: Record }) + .fields['items']!.type as { simplify(): ListType }; + // items is an AliasType('B'); its resolved target is list. + const list = items.simplify() as ListType; + expect(list.name).toBe('list'); + // The list's V is itself an alias for A; A → num{min:1, whole:true}. + const v = (list.item as { simplify(): { name: string; options: { min?: number } } }).simplify(); + expect(v.name).toBe('num'); + expect(v.options.min).toBe(1); + }); + + test('alias references generic — extra-scope T=text resolves through the alias', () => { + const fn = r.parse({ + name: 'fn', + generic: { T: { name: 'T' } }, + call: { + types: { + valueList: { name: 'list', generic: { V: { name: 'T' } } }, + }, + args: { name: 'obj', props: { items: { type: { name: 'valueList' } } } }, + returns: { name: 'T' }, + }, + }); + const local = new LocalScope(r, { T: r.text() }); + // items.type is AliasType('valueList'); its captured scope binds + // valueList → list. With the extra scope binding + // T → text, the resolved chain is list. + const items = ((fn as FnType)._call.args.props() as Record)['items']! + .type as AliasType; + const list = items.simplify(local) as ListType; + expect(list.name).toBe('list'); + expect((list.item as AliasType).simplify(local).name).toBe('text'); + }); +}); + +describe('CallDef.types — round-trip', () => { + test('toJSON preserves the source `types` map and alias references', () => { + const def: TypeDef = { + name: 'fn', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'obj', props: { a: { type: { name: 'counter' } } } }, + returns: { name: 'counter' }, + }, + }; + const fn = r.parse(def); + const json = fn.toJSON(); + expect(json.call?.types).toBeDefined(); + expect(json.call?.types?.['counter']).toEqual({ name: 'num', options: { whole: true, min: 1 } }); + // The args slot still references the alias by NAME (bare form). + expect(json.call?.args).toEqual({ name: 'obj', props: { a: { type: { name: 'counter' } } } }); + expect(json.call?.returns).toEqual({ name: 'counter' }); + }); + + test('parse → toJSON → parse produces structurally identical args', () => { + const def: TypeDef = { + name: 'fn', + call: { + types: { + A: { name: 'num', options: { min: 0 } }, + B: { name: 'list', generic: { V: { name: 'A' } } }, + }, + args: { name: 'obj', props: { xs: { type: { name: 'B' } } } }, + returns: { name: 'A' }, + }, + }; + const a = r.parse(def); + const b = r.parse(a.toJSON()); + expect((a as FnType)._call.args.toJSON()).toEqual((b as FnType)._call.args.toJSON()); + expect((a as FnType)._call.returns?.toJSON()).toEqual((b as FnType)._call.returns?.toJSON()); + }); + + test('toJSON output is stable — call-site bindings do not mutate the source', () => { + // Without an eager bind step, the FnType instance is unchanged + // regardless of which scopes consult it. toJSON always emits the + // declared shape — `T` survives bare, `box` survives. + const fn = r.parse({ + name: 'fn', + generic: { T: { name: 'T' } }, + call: { + types: { box: { name: 'list', generic: { V: { name: 'T' } } } }, + args: { name: 'obj', props: { v: { type: { name: 'box' } } } }, + returns: { name: 'T' }, + }, + }); + // Use the type with an extra scope (R=text) — this does NOT mutate + // anything; no rebuild happens. + const local = new LocalScope(r, { T: r.text() }); + fn.call(local); // exercise the call-site path + const j = fn.toJSON(); + expect(j.call?.types?.['box']).toEqual({ name: 'list', generic: { V: { name: 'T' } } }); + expect(j.call?.returns).toEqual({ name: 'T' }); + }); +}); + +describe('CallDef.types — ExprDef bodies', () => { + test('alias referenced inside `call.get` body resolves correctly', async () => { + // counterFn() => 7 (where `counter` aliases num{min:1, whole:true}) + const fnType = r.parse({ + name: 'fn', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'obj' }, + returns: { name: 'counter' }, + get: { kind: 'new', type: { name: 'counter' }, value: 7 }, + }, + }); + e.registerGlobal('counterFn', { type: fnType, value: null }); + const v = await e.run({ + kind: 'get', + path: [{ prop: 'counterFn' }, { args: {} }], + }); + expect(v.raw).toBe(7); + }); +}); + +describe('CallDef.types — toCodeDefinition rendering', () => { + test('aliases render as `type X = …;` lines before the call signature', () => { + const fn = r.parse({ + name: 'fn', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'obj', props: { n: { type: { name: 'counter' } } } }, + returns: { name: 'counter' }, + }, + }); + const def = fn.toCodeDefinition(); + const aliasIdx = def.indexOf('type counter'); + const callIdx = def.indexOf('(n:'); + expect(aliasIdx).toBeGreaterThanOrEqual(0); + expect(callIdx).toBeGreaterThanOrEqual(0); + expect(aliasIdx).toBeLessThan(callIdx); + }); +}); diff --git a/packages/gin/src/__tests__/code-format-problem.test.ts b/packages/gin/src/__tests__/code-format-problem.test.ts new file mode 100644 index 00000000..f5468a53 --- /dev/null +++ b/packages/gin/src/__tests__/code-format-problem.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, formatProblem, Code } from '../index'; + +/** + * End-to-end: take a deliberately-broken ExprDef, run engine.validate + * and engine.toGinCode, then assert formatProblem produces a + * compiler-style render with the offending source line + a `^^^` + * underline beneath it + the message line. + * + * One fixture per validator error code that's actually appeared in + * recent ginny.log output. If a future change reroutes an error to a + * different path/span, these snapshots flag it immediately. + */ +describe('formatProblem against rendered code', () => { + const r = createRegistry(); + const e = new Engine(r); + + test('var.unknown — points at the offending get path', () => { + // `getNonexistent` is a plain `get` of an unbound name. Validator + // emits `var.unknown` at path `['path', 0]`. The formatted block + // should contain the program text + an underline beneath the + // identifier + the severity-prefixed message. + const expr = { kind: 'get' as const, path: [{ prop: 'doesNotExist' }] }; + const probs = e.validate(expr); + const richCode = e.toGinCode(expr); + const out = formatProblem(richCode, probs.list[0]!); + expect(out).toContain('doesNotExist'); + // Underline characters present. + expect(out).toContain('^'); + // Severity label. + expect(out).toMatch(/error: /); + // Message body. + expect(out).toMatch(/unknown variable/); + }); + + test('define.var.type-mismatch — error message in the formatted block', () => { + // `const x: num = "wrong"` — value is text, declared type is num. + const expr = { + kind: 'define' as const, + vars: [{ + name: 'x', + type: { name: 'num' as const }, + value: { kind: 'new' as const, type: { name: 'text' as const }, value: 'wrong' }, + }], + body: { kind: 'get' as const, path: [{ prop: 'x' }] }, + }; + const probs = e.validate(expr); + const richCode = e.toGinCode(expr); + const mismatch = probs.list.find((p) => p.code === 'define.var.type-mismatch')!; + const out = formatProblem(richCode, mismatch); + expect(out).toContain('"wrong"'); + expect(out).toContain('^'); + expect(out).toMatch(/error: /); + expect(out).toMatch(/not compatible/); + }); + + test('if.condition.type — points at the bool-mismatched condition', () => { + // Condition is a num literal; should be bool. + const expr = { + kind: 'if' as const, + ifs: [{ + condition: { kind: 'new' as const, type: { name: 'num' as const }, value: 1 }, + body: { kind: 'new' as const, type: { name: 'text' as const }, value: 'yes' }, + }], + }; + const probs = e.validate(expr); + const richCode = e.toGinCode(expr); + const cond = probs.list.find((p) => p.code === 'if.condition.type'); + expect(cond).toBeDefined(); + const out = formatProblem(richCode, cond!); + // Should be a warning, not error. + expect(out).toMatch(/warning: /); + expect(out).toContain('^'); + }); + + test('falls back to path-string format when no span matches', () => { + // A Code with NO spans at all — the only branch where spanFor + // returns undefined (an empty path `[]` on a top-level span is + // always a prefix of any target, so a coarse span always + // matches if present). + const bareCode = new Code('some text'); + const fabricated = { + path: ['nonexistent'] as (string | number)[], + code: 'fake.error', + message: 'something fake', + severity: 'error' as const, + }; + const out = formatProblem(bareCode, fabricated); + expect(out).toMatch(/^error: something fake @ nonexistent$/); + }); + + test('color: false produces no ANSI escapes', () => { + const expr = { kind: 'get' as const, path: [{ prop: 'unknown' }] }; + const probs = e.validate(expr); + const richCode = e.toGinCode(expr); + const out = formatProblem(richCode, probs.list[0]!, { color: false }); + expect(out).not.toContain('\x1b['); + }); + + test('color: true emits ANSI on the underline + label', () => { + const expr = { kind: 'get' as const, path: [{ prop: 'unknown' }] }; + const probs = e.validate(expr); + const richCode = e.toGinCode(expr); + const out = formatProblem(richCode, probs.list[0]!, { color: true }); + // Red color code (escape + 31m) somewhere in output. + expect(out).toContain('\x1b[31m'); + }); +}); diff --git a/packages/gin/src/__tests__/code-render-demo.test.ts b/packages/gin/src/__tests__/code-render-demo.test.ts new file mode 100644 index 00000000..52af8c66 --- /dev/null +++ b/packages/gin/src/__tests__/code-render-demo.test.ts @@ -0,0 +1,713 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, formatProblems } from '../index'; +import type { ExprDef } from '../schema'; + +/** + * Visual demo — for each representative ExprDef, print the JSON + * input, the `toGinCode` (TS-pseudocode) render, the `toJSONCode` + * (JSON-with-spans) render, and (when validation fails) the + * compiler-style `formatProblems` output. Useful for eyeballing + * what the LLM and the user actually see. + * + * Each test asserts a basic invariant so vitest doesn't skip it, + * but the real signal is in the printed output. + * + * Run with: `npx vitest run code-render-demo --reporter=verbose` + * (or just `npx vitest run code-render-demo` and inspect the + * stdout block above the summary). + */ + +const e = new Engine(createRegistry()); + +function divider(title: string): void { + console.log('\n' + '═'.repeat(80)); + console.log(` ${title}`); + console.log('═'.repeat(80)); +} + +function section(label: string, body: string): void { + console.log(`\n── ${label} ${'─'.repeat(Math.max(0, 76 - label.length))}`); + console.log(body); +} + +function showRender(label: string, expr: ExprDef, engine: Engine = e): void { + divider(label); + + section('Input ExprDef (JSON)', JSON.stringify(expr, null, 2)); + + let ginCode = ''; + let jsonCode = ''; + try { ginCode = engine.toGinCode(expr).toString(); } + catch (err) { ginCode = ``; } + try { jsonCode = engine.toJSONCode(expr).toString(); } + catch (err) { jsonCode = ``; } + + section('toGinCode() — TS-pseudocode form', ginCode); + section('toJSONCode() — JSON form (with span annotations underneath)', jsonCode); + + const probs = engine.validate(expr); + if (probs.list.length === 0) { + section('validate()', '(no problems)'); + return; + } + section(`validate() — ${probs.list.length} problem${probs.list.length === 1 ? '' : 's'}`, + probs.list.map((p) => ` [${p.severity}] ${p.code}: ${p.message} @ ${p.path.join('.')}`).join('\n')); + + const richCode = engine.toGinCode(expr); + const jsonRichCode = engine.toJSONCode(expr); + section('formatProblems(richCode) — TS form with ^^^ pointers', + formatProblems(richCode, probs, { color: false })); + section('formatProblems(jsonCode) — JSON form with ^^^ pointers', + formatProblems(jsonRichCode, probs, { color: false })); +} + +describe('toGinCode / toJSONCode — visual rendering demo', () => { + test('1. simple add expression: x + 3', () => { + const expr: ExprDef = { + kind: 'get', + path: [ + { prop: 'x' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 3 } } }, + ], + }; + showRender('1. Simple expression — `x.add({ other: 3 })`', expr); + expect(e.toGinCode(expr).toString()).toContain('add'); + }); + + test('2. block with define + if/else', () => { + const expr: ExprDef = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [{ name: 'x', value: { kind: 'new', type: { name: 'num' }, value: 5 } }], + body: { + kind: 'if', + ifs: [{ + condition: { + kind: 'get', + path: [ + { prop: 'x' }, { prop: 'gt' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 0 } } }, + ], + }, + body: { kind: 'new', type: { name: 'text' }, value: 'positive' }, + }], + otherwise: { kind: 'new', type: { name: 'text' }, value: 'non-positive' }, + }, + }, + ], + }; + showRender('2. Composite — block + define + if/else', expr); + expect(e.toGinCode(expr).toString()).toContain('positive'); + }); + + test('3. switch with flow body (return)', () => { + const expr: ExprDef = { + kind: 'switch', + value: { kind: 'get', path: [{ prop: 'x' }] }, + cases: [{ + equals: [{ kind: 'new', type: { name: 'num' }, value: 1 }], + body: { kind: 'flow', action: 'return', value: { kind: 'new', type: { name: 'num' }, value: 99 } }, + }], + else: { kind: 'new', type: { name: 'num' }, value: 0 }, + } as ExprDef; + showRender('3. switch with flow (return) body', expr); + expect(e.toGinCode(expr).toString()).toContain('case'); + }); + + test('4. nested obj field access — args.config.host', () => { + const expr: ExprDef = { + kind: 'get', + path: [{ prop: 'args' }, { prop: 'config' }, { prop: 'host' }], + }; + showRender('4. Nested prop access — `args.config.host`', expr); + expect(e.toGinCode(expr).toString()).toContain('args.config.host'); + }); + + test('5. BROKEN — type mismatch (define with wrong-typed value)', () => { + // const x: num = "wrong" + const expr: ExprDef = { + kind: 'define', + vars: [{ + name: 'x', + type: { name: 'num' }, + value: { kind: 'new', type: { name: 'text' }, value: 'wrong' }, + }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }; + showRender('5. BROKEN — `const x: num = "wrong"` (type mismatch)', expr); + const probs = e.validate(expr); + expect(probs.list.length).toBeGreaterThan(0); + }); + + test('6. BROKEN — unknown variable in if condition', () => { + const expr: ExprDef = { + kind: 'if', + ifs: [{ + condition: { kind: 'get', path: [{ prop: 'undeclared' }] }, + body: { kind: 'new', type: { name: 'text' }, value: 'yes' }, + }], + }; + showRender('6. BROKEN — `if (undeclared)` (var.unknown)', expr); + const probs = e.validate(expr); + expect(probs.list.length).toBeGreaterThan(0); + }); + + test('7. BROKEN — multiple issues in one program', () => { + // Simulates the kind of program ginny.log shows the model writing: + // declared `text` but value is num, condition isn't bool, ref to + // undeclared var. + const expr: ExprDef = { + kind: 'define', + vars: [ + { name: 'x', type: { name: 'num' }, value: { kind: 'new', type: { name: 'text' }, value: 'oops' } }, + { name: 'y', value: { kind: 'get', path: [{ prop: 'unbound' }] } }, + ], + body: { + kind: 'if', + ifs: [{ + condition: { kind: 'new', type: { name: 'num' }, value: 1 }, + body: { kind: 'get', path: [{ prop: 'x' }] }, + }], + }, + }; + showRender('7. BROKEN — multi-error fixture', expr); + const probs = e.validate(expr); + expect(probs.list.length).toBeGreaterThanOrEqual(2); + }); + + test('8. template with placeholders + scope fallback', () => { + const expr: ExprDef = { + kind: 'block', + lines: [ + { kind: 'define', vars: [{ name: 'host', value: { kind: 'new', type: { name: 'text' }, value: 'api.example.com' } }], + body: { kind: 'new', type: { name: 'void' } } }, + { kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'https://{host}/v1' } } as ExprDef, + ], + }; + showRender('8. Template — `https://${host}/v1` with scope-fallback', expr); + expect(e.toGinCode(expr).toString()).toContain('https'); + }); + + test('9. loop over a list', () => { + const expr: ExprDef = { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3] }, + body: { kind: 'flow', action: 'continue' }, + }; + showRender('9. Loop — `for (const [key, value] of [1,2,3])`', expr); + expect(e.toGinCode(expr).toString()).toContain('for'); + }); + + test('10. lambda with constraint + body', () => { + const expr: ExprDef = { + kind: 'lambda', + type: { name: 'fn', call: { args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' } } }, + body: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'x' }, { prop: 'mul' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }, + ], + }, + }; + showRender('10. Lambda — `(args) => args.x.mul({ other: 2 })`', expr); + expect(e.toGinCode(expr).toString()).toContain('=>'); + }); + + test('11. new — typed obj literal', () => { + const expr: ExprDef = { + kind: 'new', + type: { name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }, + value: { name: 'Alice', age: 30 }, + }; + showRender('11. New obj — `new obj{name: text, age: num}({name: "Alice", age: 30})`', expr); + expect(e.toJSONCode(expr).toString()).toContain('Alice'); + }); + + test('AUTO-WRAP-1. compact args (short, fit on one line)', () => { + const expr: ExprDef = { + kind: 'get', + path: [ + { prop: 'x' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 3 } } }, + ], + }; + showRender('AUTO-WRAP-1. Compact args — `x.add({ other: 3 })`', expr); + // Should render compact, no leading newline. + expect(e.toGinCode(expr).toString()).not.toContain('\n'); + }); + + test('AUTO-WRAP-2. long args (wrap kicks in)', () => { + const expr: ExprDef = { + kind: 'get', + path: [ + { prop: 'fns' }, { prop: 'fetch' }, + { args: { + url: { kind: 'new', type: { name: 'text' }, value: 'https://api.example.com/v1/very/long/path' }, + method: { kind: 'new', type: { name: 'text' }, value: 'POST' }, + body: { kind: 'new', type: { name: 'text' }, value: 'a-fairly-long-body-string-here' }, + output: { kind: 'new', type: { name: 'typ' }, value: { name: 'text' } }, + } }, + ], + }; + showRender('AUTO-WRAP-2. Long args — wrap kicks in', expr); + // Wrap form: contains newlines. + expect(e.toGinCode(expr).toString()).toContain('\n'); + }); + + test('12. BROKEN — template with unresolved placeholder + new with no value', () => { + // Mirrors the user's "circle area" exchange: a template referencing + // a placeholder that doesn't exist anywhere, and a `new obj` with + // no value (defaults to zero). Both are real bugs the validator + // catches and `formatProblems` should pinpoint. + const expr: ExprDef = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [{ + name: 'p', + value: { kind: 'new', type: { name: 'obj', props: { x: { type: { name: 'num' } } } } }, + }], + body: { kind: 'new', type: { name: 'void' } }, + }, + { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'x={x} y={y}' }, + } as ExprDef, + ], + }; + showRender('12. BROKEN — `new obj` with no value + template missing `{y}`', expr); + const probs = e.validate(expr); + expect(probs.list.length).toBeGreaterThan(0); + }); + + test('COMPREHENSIVE-OK. all expression kinds, simple + complex, well-formed', () => { + // One program that exercises every Expr kind in both its simplest + // and most-complex form. Structured as a single outer `define` + // (so all vars are visible to each other AND to the body) whose + // body is a block of standalone expressions. + // + // define-vars (top): a, env, port, config, url, identity, double, numbers + // ↳ exercises: define (multi-var), new (scalar + obj literal), + // template (no placeholders + scope-resolved placeholders), + // lambda (identity + with constraint), get (chained call) + // body block: + // ↳ template (simple), if (simple + complex), loop (over list), + // switch (multi-case + flow continue + else), set (obj field), + // native (text.upper — registered), get (final var read) + const expr: ExprDef = { + kind: 'define', + vars: [ + // simple new + { name: 'a', value: { kind: 'new', type: { name: 'num' }, value: 1 } }, + // template scope vars + { name: 'env', value: { kind: 'new', type: { name: 'text' }, value: 'prod' } }, + { name: 'port', value: { kind: 'new', type: { name: 'num' }, value: 8080 } }, + // complex new — typed obj literal + { + name: 'config', + type: { name: 'obj', props: { env: { type: { name: 'text' } }, port: { type: { name: 'num' } } } }, + value: { + kind: 'new', + type: { name: 'obj', props: { env: { type: { name: 'text' } }, port: { type: { name: 'num' } } } }, + value: { env: 'prod', port: 8080 }, + }, + }, + // complex template — placeholders auto-resolved via scope + { + name: 'url', + value: { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'https://api-{env}.example.com:{port}' }, + }, + }, + // simple lambda — identity + { + name: 'identity', + value: { + kind: 'lambda', + type: { name: 'fn', call: { args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' } } }, + body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'x' }] }, + }, + }, + // complex lambda — with constraint, body chains methods to compute result + { + name: 'double', + value: { + kind: 'lambda', + type: { name: 'fn', call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' } } }, + constraint: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'n' }, + { prop: 'gt' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 0 } } }, + ], + }, + body: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'n' }, + { prop: 'mul' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }, + ], + }, + }, + }, + // a list to loop over + { name: 'numbers', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3] } }, + ], + body: { + kind: 'block', + lines: [ + // ─── template (simple — no placeholders) ────────────────── + // `hello world` + { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'hello world' }, + } as ExprDef, + // ─── if (simple — single branch, no else) ───────────────── + { + kind: 'if', + ifs: [{ + condition: { kind: 'new', type: { name: 'bool' }, value: true }, + body: { kind: 'new', type: { name: 'text' }, value: 'yes' }, + }], + }, + // ─── if (complex — multi-branch + else) ─────────────────── + // if (a > 0) "pos" else if (a == 0) "zero" else "neg" + { + kind: 'if', + ifs: [ + { + condition: { kind: 'get', path: [{ prop: 'a' }, { prop: 'gt' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 0 } } }] }, + body: { kind: 'new', type: { name: 'text' }, value: 'pos' }, + }, + { + condition: { kind: 'get', path: [{ prop: 'a' }, { prop: 'eq' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 0 } } }] }, + body: { kind: 'new', type: { name: 'text' }, value: 'zero' }, + }, + ], + otherwise: { kind: 'new', type: { name: 'text' }, value: 'neg' }, + }, + // ─── loop with switch + flow continue ───────────────────── + // for (key, value) in numbers { switch value { case 1, 2: continue; case 3: "three"; default: void } } + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'numbers' }] }, + body: { + kind: 'switch', + value: { kind: 'get', path: [{ prop: 'value' }] }, + cases: [ + { + equals: [ + { kind: 'new', type: { name: 'num' }, value: 1 }, + { kind: 'new', type: { name: 'num' }, value: 2 }, + ], + body: { kind: 'flow', action: 'continue' }, + }, + { + equals: [{ kind: 'new', type: { name: 'num' }, value: 3 }], + body: { kind: 'new', type: { name: 'text' }, value: 'three' }, + }, + ], + else: { kind: 'new', type: { name: 'void' } }, + }, + }, + // ─── set (simple — assign a defined obj's field) ────────── + // config.env = "dev" + { + kind: 'set', + path: [{ prop: 'config' }, { prop: 'env' }], + value: { kind: 'new', type: { name: 'text' }, value: 'dev' }, + }, + // ─── native (a registered impl: `text.upper`) ───────────── + // /* native: text.upper */ + { kind: 'native', id: 'text.upper', type: { name: 'text' } }, + // ─── get (simple — final var read) ──────────────────────── + // url + { kind: 'get', path: [{ prop: 'url' }] }, + ], + }, + }; + showRender('COMPREHENSIVE-OK — all expression kinds, well-formed', expr); + expect(e.toGinCode(expr).toString()).toContain('let'); + }); + + test('COMPREHENSIVE-BROKEN. all expression kinds, with mixed problems', () => { + // Same shape as COMPREHENSIVE-OK but with deliberate errors covering + // many validator codes: + // define.var.type-mismatch (typed num, value text) + // var.unknown (referencing undefined name) + // if.condition.type (num where bool expected) + // template.placeholder.unresolved + // new.value.missing (new obj with no value) + // lambda.returns.type (body type doesn't match returns) + // flow.outside-lambda (return at top level) + // prop.unknown (chained get on wrong type) + const expr: ExprDef = { + kind: 'block', + lines: [ + // BAD: const x: num = "wrong" (define.var.type-mismatch) + { + kind: 'define', + vars: [{ + name: 'x', type: { name: 'num' }, + value: { kind: 'new', type: { name: 'text' }, value: 'wrong' }, + }], + body: { kind: 'new', type: { name: 'void' } }, + }, + // BAD: const p = unbound (var.unknown) + { + kind: 'define', + vars: [{ name: 'p', value: { kind: 'get', path: [{ prop: 'unbound' }] } }], + body: { kind: 'new', type: { name: 'void' } }, + }, + // BAD: new obj with no value (new.value.missing) + { + kind: 'define', + vars: [{ + name: 'cfg', + value: { kind: 'new', type: { name: 'obj', props: { env: { type: { name: 'text' } } } } }, + }], + body: { kind: 'new', type: { name: 'void' } }, + }, + // BAD: template references {host} which is not in scope or params + { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'https://{host}/v1' }, + } as ExprDef, + // BAD: if condition is num, not bool + { + kind: 'if', + ifs: [{ + condition: { kind: 'new', type: { name: 'num' }, value: 1 }, + body: { kind: 'new', type: { name: 'text' }, value: 'yep' }, + }], + }, + // BAD: lambda declares returns: bool but body returns num + { + kind: 'define', + vars: [{ + name: 'shouldBeBool', + value: { + kind: 'lambda', + type: { name: 'fn', call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'bool' } } }, + body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] }, + }, + }], + body: { kind: 'new', type: { name: 'void' } }, + }, + // BAD: return at top-level (flow.outside-lambda) + { + kind: 'flow', action: 'return', + value: { kind: 'new', type: { name: 'num' }, value: 1 }, + }, + // BAD: prop.unknown — chaining a method that doesn't exist on num + { + kind: 'get', + path: [{ prop: 'x' }, { prop: 'doesNotExist' }, { args: {} }], + }, + // OK: a switch that's well-formed amongst the broken stuff so + // the demo shows mixed-state output (sections with and without + // problems both rendered). + { + kind: 'switch', + value: { kind: 'new', type: { name: 'num' }, value: 1 }, + cases: [{ + equals: [{ kind: 'new', type: { name: 'num' }, value: 1 }], + body: { kind: 'new', type: { name: 'text' }, value: 'one' }, + }], + else: { kind: 'new', type: { name: 'text' }, value: 'other' }, + } as ExprDef, + ], + }; + showRender('COMPREHENSIVE-BROKEN — all expression kinds, with mixed problems', expr); + const probs = e.validate(expr); + expect(probs.list.length).toBeGreaterThanOrEqual(5); + }); + + test('COMPREHENSIVE-OK-2. custom named types, generics, list.map, list.reduce', () => { + // Build a registry that knows about a user-named type `Point` so + // we can demonstrate (1) lambdas whose params/returns reference a + // custom registered type and (2) generic lambdas that resolve T + // via call-site bindings. Then exercise list.map and list.reduce + // with inline lambda callbacks — the higher-order list ops the + // model reaches for most often. + const r2 = createRegistry(); + const Point = r2.extend('obj', { + name: 'Point', + props: { x: { type: r2.num() }, y: { type: r2.num() } }, + }); + r2.register(Point); + const e2 = new Engine(r2); + + const expr: ExprDef = { + kind: 'define', + vars: [ + // ─── data: a list of Points ───────────────────────────────── + { + name: 'points', + value: { + kind: 'new', + type: { name: 'list', generic: { V: { name: 'Point' } } }, + value: [{ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 5, y: 6 }], + }, + }, + // ─── lambda taking a custom named type ────────────────────── + // const originDistance = (p: Point): num => + // args.p.x.mul({other: args.p.x}).add({other: args.p.y.mul({other: args.p.y})}) + // (squared distance from origin — close enough for demo) + { + name: 'originDistance', + value: { + kind: 'lambda', + type: { + name: 'fn', + call: { + args: { name: 'obj', props: { p: { type: { name: 'Point' } } } }, + returns: { name: 'num' }, + }, + }, + body: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'p' }, { prop: 'x' }, + { prop: 'mul' }, + { args: { other: { kind: 'get', path: [{ prop: 'args' }, { prop: 'p' }, { prop: 'x' }] } } }, + { prop: 'add' }, + { args: { other: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'p' }, { prop: 'y' }, + { prop: 'mul' }, + { args: { other: { kind: 'get', path: [{ prop: 'args' }, { prop: 'p' }, { prop: 'y' }] } } }, + ], + } } }, + ], + }, + }, + }, + // ─── generic lambda — identity ─────────────────────────── + // const identity = (x: T): T => args.x + { + name: 'identity', + value: { + kind: 'lambda', + type: { + name: 'fn', + call: { + generic: { T: { name: 'any' } }, + args: { name: 'obj', props: { x: { type: { name: 'T' } } } }, + returns: { name: 'T' }, + }, + }, + body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'x' }] }, + }, + }, + // ─── list.map — extract each point's x ────────────────────── + // const xs = points.map({ fn: (value: Point, index: num): num => args.value.x }) + { + name: 'xs', + value: { + kind: 'get', + path: [ + { prop: 'points' }, { prop: 'map' }, + { args: { + fn: { + kind: 'lambda', + type: { + name: 'fn', + call: { + args: { + name: 'obj', + props: { + value: { type: { name: 'Point' } }, + index: { type: { name: 'num' } }, + }, + }, + returns: { name: 'num' }, + }, + }, + body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'x' }] }, + }, + } }, + ], + }, + }, + // ─── list.reduce — sum the x coordinates ──────────────────── + // const totalX = points.reduce({ + // fn: (acc: num, value: Point, index: num): num => args.acc.add({other: args.value.x}), + // initial: 0, + // }) + { + name: 'totalX', + value: { + kind: 'get', + path: [ + { prop: 'points' }, { prop: 'reduce' }, + { args: { + fn: { + kind: 'lambda', + type: { + name: 'fn', + call: { + args: { + name: 'obj', + props: { + acc: { type: { name: 'num' } }, + value: { type: { name: 'Point' } }, + index: { type: { name: 'num' } }, + }, + }, + returns: { name: 'num' }, + }, + }, + body: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'acc' }, { prop: 'add' }, + { args: { other: { kind: 'get', path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'x' }] } } }, + ], + }, + }, + initial: { kind: 'new', type: { name: 'num' }, value: 0 }, + } }, + ], + }, + }, + ], + body: { + kind: 'block', + lines: [ + // Use the custom-typed lambda on the first point + { + kind: 'get', + path: [ + { prop: 'originDistance' }, + { args: { p: { kind: 'get', path: [{ prop: 'points' }, { key: { kind: 'new', type: { name: 'num' }, value: 0 } }] } } }, + ], + }, + // Use the generic identity with a num argument — concrete T = num + { + kind: 'get', + path: [ + { prop: 'identity' }, + { args: { x: { kind: 'new', type: { name: 'num' }, value: 42 } } }, + ], + }, + // Final value: total of x coords (validates the reduce result) + { kind: 'get', path: [{ prop: 'totalX' }] }, + ], + }, + }; + showRender( + 'COMPREHENSIVE-OK-2 — custom types, generics, list.map, list.reduce', + expr, + e2, + ); + expect(e2.toGinCode(expr).toString()).toContain('map'); + }); +}); diff --git a/packages/gin/src/__tests__/code-roundtrip.test.ts b/packages/gin/src/__tests__/code-roundtrip.test.ts new file mode 100644 index 00000000..b18f3561 --- /dev/null +++ b/packages/gin/src/__tests__/code-roundtrip.test.ts @@ -0,0 +1,123 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine } from '../index'; +import type { ExprDef } from '../schema'; + +/** + * Round-trip invariants: for representative ExprDefs the new + * `toGinCode` and `toJSONCode` must agree with the legacy + * `toCode` / `toJSON` outputs (no rendering drift), and every + * `Problem.path` produced by `validate()` must resolve to a span + * in both renderings. + * + * Catches a class of regressions where a composite Expr's override + * accidentally drops, reorders, or re-formats child output relative + * to the string-based ancestors that other code paths still rely on. + */ + +const FIXTURES: { name: string; expr: ExprDef }[] = [ + { + name: 'simple block with define + if', + expr: { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [{ name: 'x', value: { kind: 'new', type: { name: 'num' }, value: 5 } }], + body: { + kind: 'if', + ifs: [{ + condition: { + kind: 'get', + path: [ + { prop: 'x' }, { prop: 'gt' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 0 } } }, + ], + }, + body: { kind: 'new', type: { name: 'text' }, value: 'positive' }, + }], + otherwise: { kind: 'new', type: { name: 'text' }, value: 'non-positive' }, + }, + }, + ], + }, + }, + { + name: 'switch with flow body', + expr: { + kind: 'switch', + value: { kind: 'get', path: [{ prop: 'x' }] }, + cases: [ + { + equals: [{ kind: 'new', type: { name: 'num' }, value: 1 }], + body: { kind: 'flow', action: 'return', value: { kind: 'new', type: { name: 'num' }, value: 99 } }, + }, + ], + else: { kind: 'new', type: { name: 'num' }, value: 0 }, + } as ExprDef, + }, + { + name: 'set on a vars-shaped target', + expr: { + kind: 'set', + path: [{ prop: 'x' }], + value: { kind: 'new', type: { name: 'num' }, value: 7 }, + }, + }, +]; + +describe('toGinCode / toJSONCode round-trip', () => { + const r = createRegistry(); + const e = new Engine(r); + + for (const { name, expr } of FIXTURES) { + test(`${name}: toGinCode().toString() === toCode()`, () => { + const ginText = e.toGinCode(expr).toString(); + const legacyText = e.toCode(expr); + expect(ginText).toBe(legacyText); + }); + + test(`${name}: toJSONCode().toString() parses to toJSON()`, () => { + const jsonText = e.toJSONCode(expr).toString(); + const jsonExpected = JSON.stringify(r.parseExpr(expr).toJSON(), null, 2); + // The structural JSON should match. Indentation matches `null, 2`. + expect(jsonText).toBe(jsonExpected); + }); + + test(`${name}: every validator path resolves to a span`, () => { + // Build a fixture with deliberate broken bits to force problems, + // then assert each Problem.path resolves to a span in both + // renderings. For fixtures that don't naturally fail validation + // we just check that the renderings produce SOME spans. + const richCode = e.toGinCode(expr); + const jsonCode = e.toJSONCode(expr); + expect(richCode.spans.length).toBeGreaterThan(0); + expect(jsonCode.spans.length).toBeGreaterThan(0); + }); + } + + test('broken program: every Problem.path resolves in toGinCode', () => { + // Multiple deliberate errors so we exercise the path→span + // resolution beyond just one problem. + const broken: ExprDef = { + kind: 'define', + vars: [ + { name: 'x', type: { name: 'num' }, value: { kind: 'new', type: { name: 'text' }, value: 'wrong' } }, + { name: 'y', value: { kind: 'get', path: [{ prop: 'undeclared' }] } }, + ], + body: { + kind: 'if', + ifs: [{ + condition: { kind: 'new', type: { name: 'num' }, value: 1 }, + body: { kind: 'get', path: [{ prop: 'x' }] }, + }], + }, + }; + const probs = e.validate(broken); + expect(probs.list.length).toBeGreaterThan(0); + const richCode = e.toGinCode(broken); + for (const p of probs.list) { + const matched = richCode.spanFor(p.path); + expect(matched, `expected span for ${p.code} @ ${p.path.join('.')}`).toBeDefined(); + } + }); +}); diff --git a/packages/gin/src/__tests__/code-spans.test.ts b/packages/gin/src/__tests__/code-spans.test.ts new file mode 100644 index 00000000..04badedc --- /dev/null +++ b/packages/gin/src/__tests__/code-spans.test.ts @@ -0,0 +1,154 @@ +import { describe, test, expect } from 'vitest'; +import { Code, code, span, plain, joinCode, joinLines } from '../code'; + +/** + * `Code` arithmetic — concat, indent, span re-anchoring across + * newlines, line splitting, and longest-prefix path lookup. These + * are the primitives `toGinCode` / `toJSONCode` build on, so a + * regression here cascades. + */ +describe('Code primitives', () => { + test('plain text has no spans', () => { + const c = plain('hello'); + expect(c.text).toBe('hello'); + expect(c.spans).toEqual([]); + }); + + test('span() wraps text with one outer span over the whole range', () => { + const c = span('abc', { path: ['x'] }); + expect(c.text).toBe('abc'); + expect(c.spans.length).toBe(1); + expect(c.spans[0]!.start).toBe(0); + expect(c.spans[0]!.end).toBe(3); + expect(c.spans[0]!.path).toEqual(['x']); + }); + + test('code`...` interpolates strings and Codes, shifting child spans', () => { + const child = span('inner', { path: ['child'] }); + const c = code`prefix ${child} suffix`; + expect(c.text).toBe('prefix inner suffix'); + // Child span's offsets must point at "inner" within the combined text. + expect(c.spans.length).toBe(1); + expect(c.spans[0]!.start).toBe(7); + expect(c.spans[0]!.end).toBe(12); + expect(c.spans[0]!.path).toEqual(['child']); + }); + + test('concat appends and shifts spans', () => { + const a = span('aa', { path: ['a'] }); + const b = span('bb', { path: ['b'] }); + const c = a.concat(b); + expect(c.text).toBe('aabb'); + expect(c.spans.length).toBe(2); + expect(c.spans[0]!.path).toEqual(['a']); + expect(c.spans[0]!.start).toBe(0); + expect(c.spans[1]!.path).toEqual(['b']); + expect(c.spans[1]!.start).toBe(2); + expect(c.spans[1]!.end).toBe(4); + }); + + test('indent prepends to every line after the first; spans stay correct', () => { + // Line 1: "first" (no shift) + // Line 2: " second" (shifted by len(" ")=2) + // Line 3: " third" (shifted by 4 — two newlines accumulated) + const inner = span('second', { path: ['s'] }); + const c = code`first\n${inner}\nthird`; + expect(c.text).toBe('first\nsecond\nthird'); + const indented = c.indent(' '); + expect(indented.text).toBe('first\n second\n third'); + // The "second" span must still cover the original chars, + // accounting for the inserted whitespace. + const sSpan = indented.spans.find((s) => s.path[0] === 's')!; + const sliced = indented.text.slice(sSpan.start, sSpan.end); + expect(sliced).toBe('second'); + }); + + test('toLines splits multi-line text and re-anchors spans per line', () => { + const inner = span('xx', { path: ['x'] }); + const c = code`line one\n${inner} line two`; + const lines = c.toLines(); + expect(lines.length).toBe(2); + expect(lines[0]!.text).toBe('line one'); + expect(lines[0]!.lineNum).toBe(1); + expect(lines[1]!.text).toBe('xx line two'); + expect(lines[1]!.spans.length).toBe(1); + // Per-line span uses line-relative offsets. + expect(lines[1]!.spans[0]!.start).toBe(0); + expect(lines[1]!.spans[0]!.end).toBe(2); + }); + + test('toLines clips multi-line spans to each line', () => { + // A single span covering BOTH lines should appear in BOTH lines' + // spans arrays, with offsets clipped to that line. + const c = new Code('foo\nbar', [{ start: 0, end: 7, path: ['whole'] }]); + const lines = c.toLines(); + expect(lines[0]!.spans.length).toBe(1); + expect(lines[0]!.spans[0]!.end).toBe(3); // "foo" length + expect(lines[1]!.spans.length).toBe(1); + expect(lines[1]!.spans[0]!.start).toBe(0); + expect(lines[1]!.spans[0]!.end).toBe(3); // "bar" length + }); + + test('spanFor finds longest-prefix match', () => { + // Three nested spans — outer covers everything, mid covers a sub- + // range, inner is the most specific. Looking up + // `['outer', 'mid', 'inner', 'leaf']` should pick the inner span. + const c = new Code('aaaaaaaaaa', [ + { start: 0, end: 10, path: ['outer'] }, + { start: 2, end: 8, path: ['outer', 'mid'] }, + { start: 4, end: 6, path: ['outer', 'mid', 'inner'] }, + ]); + const found = c.spanFor(['outer', 'mid', 'inner', 'leaf']); + expect(found?.path).toEqual(['outer', 'mid', 'inner']); + }); + + test('spanFor returns undefined when no span matches', () => { + const c = new Code('xy', [{ start: 0, end: 2, path: ['a'] }]); + expect(c.spanFor(['b'])).toBeUndefined(); + }); + + test('spanFor breaks ties by smaller (more specific) range', () => { + // Two spans with identical paths but different ranges — pick the + // tighter one. + const c = new Code('xxxxxx', [ + { start: 0, end: 6, path: ['a'] }, + { start: 1, end: 3, path: ['a'] }, + ]); + const found = c.spanFor(['a']); + expect(found?.start).toBe(1); + expect(found?.end).toBe(3); + }); + + test('joinCode preserves spans across separator', () => { + const a = span('a', { path: ['a'] }); + const b = span('b', { path: ['b'] }); + const joined = joinCode([a, b], '|'); + expect(joined.text).toBe('a|b'); + expect(joined.spans.find((s) => s.path[0] === 'a')?.start).toBe(0); + expect(joined.spans.find((s) => s.path[0] === 'b')?.start).toBe(2); + }); + + test('joinLines joins with newline, preserves spans', () => { + const a = span('a', { path: ['a'] }); + const b = span('b', { path: ['b'] }); + const joined = joinLines([a, b]); + expect(joined.text).toBe('a\nb'); + const lines = joined.toLines(); + expect(lines.length).toBe(2); + expect(lines[0]!.text).toBe('a'); + expect(lines[1]!.text).toBe('b'); + }); + + test('toString returns the underlying text', () => { + const c = code`hi ${span('there', { path: ['t'] })}`; + expect(c.toString()).toBe('hi there'); + expect(`${c}`).toBe('hi there'); + }); + + test('empty Code has zero lines… wait, one empty line', () => { + const c = new Code(''); + const lines = c.toLines(); + expect(lines.length).toBe(1); + expect(lines[0]!.text).toBe(''); + }); +}); diff --git a/packages/gin/src/__tests__/color.test.ts b/packages/gin/src/__tests__/color.test.ts index c84d0007..b808d56f 100644 --- a/packages/gin/src/__tests__/color.test.ts +++ b/packages/gin/src/__tests__/color.test.ts @@ -33,7 +33,7 @@ describe('ColorType', () => { test('init spec exposes r/g/b/a args', () => { const i = r.color().init(); expect(i).toBeDefined(); - expect(i!.args.name).toBe('object'); + expect(i!.args.name).toBe('obj'); }); test('props include components + manipulation + conversion', () => { diff --git a/packages/gin/src/__tests__/composite-values.test.ts b/packages/gin/src/__tests__/composite-values.test.ts index 1bf4de8b..a1c76fd9 100644 --- a/packages/gin/src/__tests__/composite-values.test.ts +++ b/packages/gin/src/__tests__/composite-values.test.ts @@ -34,8 +34,8 @@ describe('composite values preserve actual element types', () => { test('obj with interface field retains the concrete type of the stored value', () => { const r = createRegistry(); const comparable = r.iface({ - props: { toText: { type: { name: 'function', call: { - args: { name: 'object' }, returns: { name: 'text' }, + props: { toText: { type: { name: 'fn', call: { + args: { name: 'obj' }, returns: { name: 'text' }, } } } }, }); const box = r.obj({ thing: { type: comparable } }); diff --git a/packages/gin/src/__tests__/constraints.test.ts b/packages/gin/src/__tests__/constraints.test.ts index 816979f4..52f82eef 100644 --- a/packages/gin/src/__tests__/constraints.test.ts +++ b/packages/gin/src/__tests__/constraints.test.ts @@ -119,9 +119,9 @@ describe('Lambda constraints', () => { const lambda = r.parseExpr({ kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { x: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, diff --git a/packages/gin/src/__tests__/deep-set.test.ts b/packages/gin/src/__tests__/deep-set.test.ts index f867542b..63789556 100644 --- a/packages/gin/src/__tests__/deep-set.test.ts +++ b/packages/gin/src/__tests__/deep-set.test.ts @@ -108,9 +108,9 @@ describe('set return value + safe-navigation', () => { test('safe-nav does NOT short-circuit a call step (Fn raw may be null)', async () => { const r = createRegistry(); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'get', @@ -196,7 +196,7 @@ describe('deep set: method call → prop set', () => { }, }, self: { - type: r.fn(r.obj({}), r.ref('tattler')), + type: r.fn(r.obj({}), r.alias('tattler')), get: { kind: 'get', path: [{ prop: 'this' }] }, }, }, @@ -237,7 +237,7 @@ describe('deep set: field → index set', () => { value: { kind: 'new', type: { - name: 'object', + name: 'obj', props: { inner: { type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } } } }, }, value: { inner: [['a', 1]] }, @@ -270,9 +270,9 @@ describe('deep set: method call with CallDef.set', () => { const r = createRegistry(); // Method whose Fn type has call.set — set body pushes {args.key, value} to log. const setterFn = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { key: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { key: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'block', @@ -337,9 +337,9 @@ describe('deep set: direct call with CallDef.set', () => { test('`fn(args) = value` invokes the Fn\'s call.set', async () => { const r = createRegistry(); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'get', @@ -381,7 +381,7 @@ describe('deep set: direct call with CallDef.set', () => { name: 'fn', value: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }], diff --git a/packages/gin/src/__tests__/define-type-inference.test.ts b/packages/gin/src/__tests__/define-type-inference.test.ts new file mode 100644 index 00000000..cc67f377 --- /dev/null +++ b/packages/gin/src/__tests__/define-type-inference.test.ts @@ -0,0 +1,218 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, ListType } from '../index'; +import type { DefineExprDef } from '../index'; + +/** + * `DefineExpr` lets callers omit `type` per-var; the type is inferred + * from the value's static type (`new` carries its type, `get` walks + * the path to a target type, `if`/`block` infers from the branches, + * etc.). These tests pin that behavior down end-to-end: + * + * - Inference: `typeOf` of a typeless var matches the value's typeOf. + * - Chaining: `vars[i].value` may reference any earlier var by name — + * runtime, typeOf, AND validateWalk all see the updated scope. + * - Round-trip: omitting `type` survives `toJSON()` (no spurious + * `type: undefined` in the serialized form). + * - Mismatch is an error severity, not a warning, when an explicit + * type contradicts the value's inferred type. + */ + +const e = new Engine(createRegistry()); +const r = e.registry; + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; +const txtLit = (s: string) => ({ kind: 'new', type: { name: 'text' }, value: s }) as const; + +describe('Define — type is optional and inferred from the value', () => { + test('runtime: typeless var binds the value just fine', async () => { + const v = await e.run({ + kind: 'define', + vars: [{ name: 'x', value: numLit(42) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(v.raw).toBe(42); + }); + + test('typeOf: omitted type is inferred from the value', () => { + const t = e.typeOf({ + kind: 'define', + vars: [{ name: 'x', value: numLit(7) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(t.name).toBe('num'); + }); + + test('typeOf: list value yields a list type with the right element', () => { + const t = e.typeOf({ + kind: 'define', + vars: [{ + name: 'xs', + value: { + kind: 'new', + type: { name: 'list', generic: { V: { name: 'num' } } }, + value: [numLit(1), numLit(2)], + }, + }], + body: { kind: 'get', path: [{ prop: 'xs' }] }, + }); + expect(t.name).toBe('list'); + expect(t).toBeInstanceOf(ListType); + expect((t as ListType).item.name).toBe('num'); + }); + + test('validate: typeless var produces no problems on a clean program', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(probs.list).toHaveLength(0); + }); +}); + +describe('Define — chaining: each var sees previous vars', () => { + test('runtime: var2 reads var1 by name', async () => { + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(10) }, + { name: 'y', value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(v.raw).toBe(10); + }); + + test('runtime: var3 can chain through var2 → var1', async () => { + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'a', value: numLit(2) }, + { + name: 'b', + value: { + kind: 'get', + path: [ + { prop: 'a' }, { prop: 'add' }, + { args: { other: numLit(3) } }, + ], + }, + }, + { + name: 'c', + value: { + kind: 'get', + path: [ + { prop: 'b' }, { prop: 'mul' }, + { args: { other: numLit(2) } }, + ], + }, + }, + ], + body: { kind: 'get', path: [{ prop: 'c' }] }, + }); + expect(v.raw).toBe(10); // (2 + 3) * 2 + }); + + test('typeOf: later var inherits the type of the earlier it references', () => { + const t = e.typeOf({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(1) }, + { name: 'y', value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(t.name).toBe('num'); + }); + + test('validate: walking var2.value uses the updated scope so var1 is known', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(1) }, + // If validateWalk used the parent scope, this `get` would + // produce a `var.unknown` problem for `x`. It mustn't. + { name: 'y', value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(probs.list.some((p) => p.code === 'var.unknown')).toBe(false); + expect(probs.list).toHaveLength(0); + }); + + test('validate: var3 referencing var1 through var2 path still resolves', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'a', value: numLit(2) }, + { name: 'b', value: { kind: 'get', path: [{ prop: 'a' }] } }, + { name: 'c', value: { kind: 'get', path: [{ prop: 'b' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'c' }] }, + }); + expect(probs.list).toHaveLength(0); + }); +}); + +describe('Define — explicit type still works alongside inference', () => { + test('explicit type matching value → ok', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', type: { name: 'num' }, value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(probs.list).toHaveLength(0); + }); + + test('explicit type mismatching value → error severity', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', type: { name: 'num' }, value: txtLit('nope') }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + const mm = probs.list.find((p) => p.code === 'define.var.type-mismatch'); + expect(mm).toBeDefined(); + expect(mm!.severity).toBe('error'); + expect(mm!.path).toEqual(['vars', 0, 'value']); + }); + + test('chained vars: explicit type on var2 mismatching var1.type → error', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(5) }, + // var2 declares text but the value reads var1 (num). Should + // be a type-mismatch error, not a silently-passing program. + { name: 'y', type: { name: 'text' }, value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(probs.list.some((p) => p.code === 'define.var.type-mismatch' && p.severity === 'error')).toBe(true); + }); +}); + +describe('Define — JSON round-trip preserves omitted type', () => { + test('toJSON does not emit a type field when none was set', () => { + const def: DefineExprDef = { + kind: 'define', + vars: [{ name: 'x', value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }; + const expr = r.parseExpr(def); + const back = expr.toJSON() as DefineExprDef; + expect(back.vars[0]).toEqual({ name: 'x', value: numLit(1) }); + expect('type' in back.vars[0]!).toBe(false); + }); + + test('toJSON preserves a type field when it was set', () => { + const def: DefineExprDef = { + kind: 'define', + vars: [{ name: 'x', type: { name: 'num' }, value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }; + const expr = r.parseExpr(def); + const back = expr.toJSON() as DefineExprDef; + expect(back.vars[0]!.type).toEqual({ name: 'num' }); + }); +}); diff --git a/packages/gin/src/__tests__/duration.test.ts b/packages/gin/src/__tests__/duration.test.ts index 8f4bf0cc..58871636 100644 --- a/packages/gin/src/__tests__/duration.test.ts +++ b/packages/gin/src/__tests__/duration.test.ts @@ -29,7 +29,7 @@ describe('DurationType', () => { test('init spec exposes component args', () => { const i = r.duration().init(); expect(i).toBeDefined(); - expect(i!.args.name).toBe('object'); + expect(i!.args.name).toBe('obj'); expect(i!.run).toBeDefined(); }); diff --git a/packages/gin/src/__tests__/expr-validate.test.ts b/packages/gin/src/__tests__/expr-validate.test.ts index d7b63019..6619f18d 100644 --- a/packages/gin/src/__tests__/expr-validate.test.ts +++ b/packages/gin/src/__tests__/expr-validate.test.ts @@ -62,8 +62,8 @@ describe('LambdaExpr validation', () => { test('body type incompatible with declared returns → warn', () => { const probs = e.validate({ kind: 'lambda', - type: { name: 'function', call: { - args: { name: 'object' }, + type: { name: 'fn', call: { + args: { name: 'obj' }, returns: { name: 'num' }, } }, body: { kind: 'new', type: { name: 'text' }, value: 'wrong' }, @@ -74,8 +74,8 @@ describe('LambdaExpr validation', () => { test('body type matches declared returns → no warn', () => { const probs = e.validate({ kind: 'lambda', - type: { name: 'function', call: { - args: { name: 'object' }, + type: { name: 'fn', call: { + args: { name: 'obj' }, returns: { name: 'num' }, } }, body: { kind: 'new', type: { name: 'num' }, value: 42 }, @@ -89,7 +89,7 @@ describe('TemplateExpr validation', () => { const probs = e.validate({ kind: 'template', template: { kind: 'new', type: { name: 'num' }, value: 42 }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, }); expect(probs.list.some((p) => p.code === 'template.template.type')).toBe(true); }); @@ -107,7 +107,7 @@ describe('TemplateExpr validation', () => { const probs = e.validate({ kind: 'template', template: { kind: 'new', type: { name: 'text' }, value: 'hi' }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, }); expect(probs.list.some((p) => p.code === 'template.template.type')).toBe(false); expect(probs.list.some((p) => p.code === 'template.params.type')).toBe(false); @@ -149,7 +149,7 @@ describe('SetExpr validation', () => { }); describe('DefineExpr validation', () => { - test('declared var type incompatible with value → warn', () => { + test('declared var type incompatible with value → error', () => { const probs = e.validate({ kind: 'define', vars: [{ @@ -159,10 +159,12 @@ describe('DefineExpr validation', () => { }], body: { kind: 'get', path: [{ prop: 'x' }] }, }); - expect(probs.list.some((p) => p.code === 'define.var.type-mismatch')).toBe(true); + const mismatch = probs.list.find((p) => p.code === 'define.var.type-mismatch'); + expect(mismatch).toBeDefined(); + expect(mismatch!.severity).toBe('error'); }); - test('declared var type matches value → no warn', () => { + test('declared var type matches value → no error', () => { const probs = e.validate({ kind: 'define', vars: [{ diff --git a/packages/gin/src/__tests__/exprs-block-if-switch.test.ts b/packages/gin/src/__tests__/exprs-block-if-switch.test.ts index 3f844cc6..d2557e75 100644 --- a/packages/gin/src/__tests__/exprs-block-if-switch.test.ts +++ b/packages/gin/src/__tests__/exprs-block-if-switch.test.ts @@ -82,4 +82,45 @@ describe('evalSwitch', () => { }); expect(v.raw).toBe(-1); }); + + test('toCode: bodies render as plain indented statements (no out-of-sync braces)', () => { + // Reproduces the user's example. The previous `renderStatementBody` + // wrapping in `{ ... }` and re-indenting via `indentCode` shifted + // the closing brace above the break statement; this asserts the + // new clean form: case label, body line(s) at +4, optional break + // at +4, default likewise. + const code = e.toCode({ + kind: 'switch', + value: { kind: 'get', path: [{ prop: 'y' }] }, + cases: [{ + equals: [{ kind: 'new', type: { name: 'num' }, value: 5 }], + body: { kind: 'new', type: { name: 'text' }, value: 'y is five' }, + }], + else: { kind: 'new', type: { name: 'text' }, value: 'y is not five' }, + } as any); + expect(code).toBe( + 'switch (y) {\n' + + ' case 5:\n' + + ' "y is five";\n' + + ' break;\n' + + ' default:\n' + + ' "y is not five";\n' + + '}', + ); + }); + + test('toCode: case body that is a `flow` skips the auto-`break`', () => { + // A return / throw / exit terminates control flow on its own — + // appending `break;` after would be unreachable. + const code = e.toCode({ + kind: 'switch', + value: { kind: 'get', path: [{ prop: 'x' }] }, + cases: [{ + equals: [{ kind: 'new', type: { name: 'num' }, value: 1 }], + body: { kind: 'flow', action: 'return', value: { kind: 'new', type: { name: 'num' }, value: 99 } }, + }], + } as any); + expect(code).toContain('case 1:\n return 99;'); + expect(code).not.toContain('return 99;\n break;'); + }); }); diff --git a/packages/gin/src/__tests__/exprs-lambda-template.test.ts b/packages/gin/src/__tests__/exprs-lambda-template.test.ts index f480b28a..62a92ce8 100644 --- a/packages/gin/src/__tests__/exprs-lambda-template.test.ts +++ b/packages/gin/src/__tests__/exprs-lambda-template.test.ts @@ -23,9 +23,9 @@ describe('evalLambda + list.map', () => { fn: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { value: { type: { name: 'num' } }, index: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { value: { type: { name: 'num' } }, index: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, @@ -65,7 +65,7 @@ describe('evalLambda + list.map', () => { args: { fn: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'bool' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, body: { kind: 'get', path: [ @@ -93,7 +93,7 @@ describe('evalTemplate', () => { template: { kind: 'new', type: { name: 'text' }, value: 'Hello {name}, you have {count} messages' }, params: { kind: 'new', - type: { name: 'object', props: { name: { type: { name: 'text' } }, count: { type: { name: 'num' } } } }, + type: { name: 'obj', props: { name: { type: { name: 'text' } }, count: { type: { name: 'num' } } } }, value: { name: 'Alice', count: 3 }, }, }); @@ -104,8 +104,255 @@ describe('evalTemplate', () => { const v = await e.run({ kind: 'template', template: { kind: 'new', type: { name: 'text' }, value: 'hi {missing}' }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, }); expect(v.raw).toBe('hi '); }); + + test('falls back to scope variables when params is omitted', async () => { + // Template can reference any local variable directly — no + // explicit params object needed. This is the common case the + // LLM hits when it writes `${baseUrl}` expecting the local + // `define baseUrl = ...` to flow through. + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'host', value: { kind: 'new', type: { name: 'text' }, value: 'api.example.com' } }, + { name: 'port', value: { kind: 'new', type: { name: 'num' }, value: 8080 } }, + ], + body: { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'https://{host}:{port}/v1' }, + // No `params` — `{host}` and `{port}` resolve via scope. + }, + }); + expect(v.raw).toBe('https://api.example.com:8080/v1'); + }); + + test('partial params + scope fallback for the rest', async () => { + // params supplies one placeholder; the other comes from scope. + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'host', value: { kind: 'new', type: { name: 'text' }, value: 'api.example.com' } }, + ], + body: { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: '{host}/{path}' }, + params: { + kind: 'new', + type: { name: 'obj', props: { path: { type: { name: 'text' } } } }, + value: { path: 'users' }, + }, + }, + }); + expect(v.raw).toBe('api.example.com/users'); + }); + + test('params overrides scope when both have the same name', async () => { + // Explicit params is meant to be the override path — values + // there take precedence over a same-named scope variable. + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'name', value: { kind: 'new', type: { name: 'text' }, value: 'from scope' } }, + ], + body: { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'hello {name}' }, + params: { + kind: 'new', + type: { name: 'obj', props: { name: { type: { name: 'text' } } } }, + value: { name: 'from params' }, + }, + }, + }); + expect(v.raw).toBe('hello from params'); + }); + + test('validate: ERRORS on unresolved placeholder (not in params, not in scope)', () => { + const probs = e.validate({ + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'x={x} y={y}' }, + params: { + kind: 'new', + type: { name: 'obj', props: { x: { type: { name: 'num' } } } }, + value: { x: 1 }, + }, + }); + // `{x}` is in params, `{y}` is in neither params nor scope. + // Unresolved placeholders silently produce empty strings at + // runtime — a real bug — so promote to error severity. + const unresolved = probs.list.find((p) => p.code === 'template.placeholder.unresolved'); + expect(unresolved).toBeDefined(); + expect(unresolved?.severity).toBe('error'); + expect(unresolved?.message).toContain("'{y}'"); + }); + + test('validate: typed-obj params (a `get`) participates in the keys check', () => { + // params is `args.config` — we don't know the inline value, but + // we DO know its declared type (obj{baseUrl, apiKey}). The + // validator uses those keys to decide which placeholders are + // satisfied, so `{baseUrl}` is fine and `{port}` errors. + const probs = e.validate( + { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: '{baseUrl}:{port}/x?key={apiKey}' }, + params: { kind: 'get', path: [{ prop: 'config' }] }, + }, + // Scope binds `config` to a typed obj. + new Map([ + ['config', e.registry.obj({ + baseUrl: { type: e.registry.text() }, + apiKey: { type: e.registry.text() }, + })], + ]), + ); + const unresolved = probs.list.filter((p) => p.code === 'template.placeholder.unresolved'); + // {baseUrl} and {apiKey} are keys of the params type. + // {port} is not — error on that one only. + expect(unresolved.length).toBe(1); + expect(unresolved[0]!.message).toContain("'{port}'"); + }); + + test('validate: opaque `any` params defers to scope-only check', () => { + // When params is typed `any` we can't see its shape — the + // validator falls back to scope-only checking. `{x}` is in + // scope, `{y}` is not. + const probs = e.validate( + { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'x={x} y={y}' }, + params: { kind: 'get', path: [{ prop: 'opaque' }] }, + }, + new Map([ + ['opaque', e.registry.any()], + ['x', e.registry.num()], + ]), + ); + const unresolved = probs.list.filter((p) => p.code === 'template.placeholder.unresolved'); + expect(unresolved.length).toBe(1); + expect(unresolved[0]!.message).toContain("'{y}'"); + }); + + test('validate: scope fallback satisfies the placeholder check', () => { + // The same `{y}` placeholder, this time bound by an outer + // `define` — should NOT warn because runtime will pick it up + // via scope fallback. + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'y', value: { kind: 'new', type: { name: 'num' }, value: 9 } }], + body: { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'y={y}' }, + }, + }); + expect(probs.list.some((p) => p.code === 'template.placeholder.unresolved')).toBe(false); + }); + + test('toCode: bare template (no params) renders placeholders as ${name}', async () => { + const e2 = new Engine(createRegistry()); + const code = e2.toCode({ + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'hello {who}' }, + }); + expect(code).toBe('`hello ${who}`'); + }); + + test('toCode: literal-inline params renders without with()', async () => { + const e2 = new Engine(createRegistry()); + const code = e2.toCode({ + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'n={n}' }, + params: { + kind: 'new', + type: { name: 'obj', props: { n: { type: { name: 'num' } } } }, + value: { n: 42 }, + }, + }); + // Inline literal — no `with(...)` clause needed. + expect(code).toContain('${42}'); + expect(code).not.toContain('with('); + }); + + test('toCode: non-inlinable params drops to bare placeholders (no with() clause)', async () => { + // params is a `get` Expr (not a `new obj` literal), so its + // field values can't be inlined at toCode time. Bare `${name}` + // wins — the runtime falls through to scope, which is what the + // user wrote `params: args.config` for in the first place. No + // `with(...)` clause: it added noise without adding info. + const e2 = new Engine(createRegistry()); + const code = e2.toCode({ + kind: 'define', + vars: [{ + name: 'p', + value: { + kind: 'new', + type: { name: 'obj', props: { name: { type: { name: 'text' } } } }, + value: { name: 'alice' }, + }, + }], + body: { + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'hi {name}' }, + params: { kind: 'get', path: [{ prop: 'p' }] }, + }, + }); + expect(code).toContain('${name}'); + expect(code).not.toContain('with('); + }); + + test('toCode: literal params with Expr values inlines the Expr toCode', async () => { + // params is `new obj{ apiKey: }` — a literal + // obj whose fields are themselves Exprs. Each `{name}` lookup + // grabs the matching field's Expr code and substitutes it. + const e2 = new Engine(createRegistry()); + const code = e2.toCode({ + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'auth={apiKey}' }, + params: { + kind: 'new', + type: { name: 'obj', props: { apiKey: { type: { name: 'text' } } } }, + value: { + apiKey: { kind: 'get', path: [{ prop: 'vars' }, { prop: 'marketstackApiKey' }] }, + }, + }, + }); + // The `{apiKey}` placeholder should be replaced by the rendered + // get path, not left as `${apiKey}` or trailed by a `with()`. + expect(code).toContain('${vars.marketstackApiKey}'); + expect(code).not.toContain('with('); + expect(code).not.toContain('${apiKey}'); + }); + + test('toCode: long inline code (>64 chars) wraps to multi-line ${\\n code\\n}', async () => { + // Build a deeply-nested chain that produces > 64 chars of code. + // `vars.x.toText.upper.lower.toText.upper.lower` is over the + // threshold — it should render across three lines so the + // template doesn't sprawl horizontally. + const e2 = new Engine(createRegistry()); + const longExpr = { + kind: 'get', + path: [ + { prop: 'vars' }, { prop: 'someExtremelyLongIdentifierName' }, + { prop: 'toText' }, { prop: 'upper' }, { prop: 'lower' }, + { prop: 'toText' }, { prop: 'upper' }, { prop: 'lower' }, + ], + }; + const code = e2.toCode({ + kind: 'template', + template: { kind: 'new', type: { name: 'text' }, value: 'x={x}' }, + params: { + kind: 'new', + type: { name: 'obj', props: { x: { type: { name: 'text' } } } }, + value: { x: longExpr }, + }, + }); + // The long code should be wrapped on its own line. The marker + // is the `${\n` that opens the multi-line interpolation. + expect(code).toContain('${\n'); + expect(code).toContain('\n}'); + // And the inline form should NOT be present. + expect(code).not.toMatch(/\$\{vars\.someExtremelyLongIdentifierName/); + }); }); diff --git a/packages/gin/src/__tests__/exprs-new.test.ts b/packages/gin/src/__tests__/exprs-new.test.ts index c179475d..b7b770d6 100644 --- a/packages/gin/src/__tests__/exprs-new.test.ts +++ b/packages/gin/src/__tests__/exprs-new.test.ts @@ -42,4 +42,54 @@ describe('evalNew', () => { const v = await e.run({ kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } } }); expect(primitives(v)).toEqual([]); }); + + test('validate: warns on `new obj` with required fields and no value', () => { + // The actual case from the user's exchange: a template's params + // built as `new obj{radius: num, area: num}` with NO value. At + // runtime each field defaults to 0; `${radius}` and `${area}` + // silently substitute zero, masking the missing computation. + // The warning gives the model a chance to fix this before the + // test() call swallows the bug. + const probs = e.validate({ + kind: 'new', + type: { + name: 'obj', + props: { radius: { type: { name: 'num' } }, area: { type: { name: 'num' } } }, + }, + }); + const warn = probs.list.find((p) => p.code === 'new.value.missing'); + expect(warn).toBeDefined(); + }); + + test('validate: no warning when `new obj` has only optional fields', () => { + // Optional fields default to undefined/null which IS a meaningful + // value (not a silent zero), so a missing value is acceptable. + const probs = e.validate({ + kind: 'new', + type: { + name: 'obj', + props: { + opt: { type: { name: 'optional', generic: { T: { name: 'num' } } } }, + }, + }, + }); + expect(probs.list.some((p) => p.code === 'new.value.missing')).toBe(false); + }); + + test('validate: no warning when `new obj` provides a value', () => { + const probs = e.validate({ + kind: 'new', + type: { name: 'obj', props: { x: { type: { name: 'num' } } } }, + value: { x: 5 }, + }); + expect(probs.list.some((p) => p.code === 'new.value.missing')).toBe(false); + }); + + test('validate: no warning for `new list` (empty list is fine)', () => { + const probs = e.validate({ + kind: 'new', + type: { name: 'list', generic: { V: { name: 'num' } } }, + }); + expect(probs.list.some((p) => p.code === 'new.value.missing')).toBe(false); + }); }); diff --git a/packages/gin/src/__tests__/extensibility.test.ts b/packages/gin/src/__tests__/extensibility.test.ts index 4eafdbd0..cd0a0876 100644 --- a/packages/gin/src/__tests__/extensibility.test.ts +++ b/packages/gin/src/__tests__/extensibility.test.ts @@ -110,7 +110,7 @@ describe('extensibility: Type.toCode is fully polymorphic', () => { r.enum({ A: 'a' }, r.text()), r.literal(r.num(), 7), r.fn(r.obj({}), r.num()), r.iface({ props: { x: { type: { name: 'num' } } } }), - r.ref('Foo'), r.generic('T'), + r.alias('Foo'), r.alias('T'), r.date(), r.timestamp(), r.duration(), r.color(), r.extend('num', { name: 'Positive', options: { min: 0 } }), ]; diff --git a/packages/gin/src/__tests__/extension-generics.test.ts b/packages/gin/src/__tests__/extension-generics.test.ts index 1421cbe7..899c4892 100644 --- a/packages/gin/src/__tests__/extension-generics.test.ts +++ b/packages/gin/src/__tests__/extension-generics.test.ts @@ -1,23 +1,22 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../index'; import { Extension } from '../extension'; -import { GenericType } from '../types/generic'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; /** * Extensions can declare their own generic parameters. The parameters - * live on `local.generic` (decl + current binding map), are substituted - * via `.bind({T: ...})`, and propagate through local.props / get / call. - * - * Convention: use `registry.generic('T')` as both the declaration value - * AND the placeholder inside props/etc. That way `.bind(...)` updates - * the declared binding AND the usage sites in one substitute walk. + * live on `local.generic` (decl + current binding map) and are + * referenced as `r.alias('T')` inside `props`/`get`/`call`. There is + * no eager `bind` machinery — call sites pass an extra `TypeScope` + * binding T to a concrete type, and AliasType resolution sees it. */ describe('Extension generics', () => { const r = createRegistry(); - test('declare + bind: Box', () => { - const T = r.generic('T'); - const Box = r.extend('object', { + test('declare: Box placeholders survive as AliasType', () => { + const T = r.alias('T'); + const Box = r.extend('obj', { name: 'Box', generic: { T }, props: { @@ -26,22 +25,32 @@ describe('Extension generics', () => { }); r.register(Box); - // Template: T unbound - expect(Box.generic.T).toBeInstanceOf(GenericType); - expect((Box as Extension).local.props!.value!.type).toBeInstanceOf(GenericType); + expect(Box.generic.T).toBeInstanceOf(AliasType); + expect((Box as Extension).local.props!.value!.type).toBeInstanceOf(AliasType); + }); + + test('Box resolution: extra-scope T=num makes value.type behave as num', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Box = reg.extend('obj', { + name: 'Box', + generic: { T }, + props: { value: { type: T } }, + }); + reg.register(Box); - // Bind T = num - const NumBox = Box.bind({ T: r.num() }); - expect(NumBox).toBeInstanceOf(Extension); - expect(NumBox.generic.T!.name).toBe('num'); - // The prop's type is now num, not a placeholder. - expect((NumBox as Extension).local.props!.value!.type.name).toBe('num'); + const local = new LocalScope(reg, { T: reg.num() }); + const valueProp = (Box as Extension).local.props!.value!; + expect((valueProp.type as AliasType).simplify(local).name).toBe('num'); + expect(valueProp.type.valid(5, local)).toBe(true); + expect(valueProp.type.valid('x', local)).toBe(false); }); - test('multi-param: Pair', () => { - const A = r.generic('A'); - const B = r.generic('B'); - const Pair = r.extend('object', { + test('multi-param: Pair via extra-scope', () => { + const reg = createRegistry(); + const A = reg.alias('A'); + const B = reg.alias('B'); + const Pair = reg.extend('obj', { name: 'Pair', generic: { A, B }, props: { @@ -49,34 +58,39 @@ describe('Extension generics', () => { second: { type: B }, }, }); - r.register(Pair); + reg.register(Pair); - const NumText = Pair.bind({ A: r.num(), B: r.text() }); - expect((NumText as Extension).local.props!.first!.type.name).toBe('num'); - expect((NumText as Extension).local.props!.second!.type.name).toBe('text'); + const local = new LocalScope(reg, { A: reg.num(), B: reg.text() }); + expect((Pair as Extension).local.props!.first!.type.valid(5, local)).toBe(true); + expect((Pair as Extension).local.props!.first!.type.valid('x', local)).toBe(false); + expect((Pair as Extension).local.props!.second!.type.valid('x', local)).toBe(true); + expect((Pair as Extension).local.props!.second!.type.valid(5, local)).toBe(false); }); - test('generic on call: identity(x: T): T', () => { - const T = r.generic('T'); - const Fn = r.extend('function', { + test('generic on call: identity(x: T): T resolves via extra-scope', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Fn = reg.extend('fn', { name: 'identity', generic: { T }, - call: { args: r.obj({ x: { type: T } }), returns: T }, + call: { args: reg.obj({ x: { type: T } }), returns: T }, }); - r.register(Fn); - - const bound = Fn.bind({ T: r.num() }); - const call = bound.call()!; - expect(call.returns!.name).toBe('num'); - // args is an obj; its `x` field is num. - const argsObj = call.args; - expect(argsObj.prop('x')!.type.name).toBe('num'); + reg.register(Fn); + + const local = new LocalScope(reg, { T: reg.num() }); + const call = Fn.call(local)!; + // `returns` is AliasType('T'); .simplify(local) → num. + expect(call.returns?.simplify(local).name).toBe('num'); + // args is an obj; its `x` field, accessed with local scope, + // resolves through to num. + expect(call.args.prop('x', local)!.type.valid(5, local)).toBe(true); }); - test('generic on get: Bag[K]: V', () => { - const K = r.generic('K'); - const V = r.generic('V'); - const Bag = r.extend('object', { + test('generic on get: Bag[K]: V resolves via extra-scope', () => { + const reg = createRegistry(); + const K = reg.alias('K'); + const V = reg.alias('V'); + const Bag = reg.extend('obj', { name: 'Bag', generic: { K, V }, get: { @@ -84,48 +98,53 @@ describe('Extension generics', () => { value: V, }, }); - r.register(Bag); + reg.register(Bag); - const bound = Bag.bind({ K: r.text(), V: r.num() }); - const gs = bound.get()!; - expect(gs.key.name).toBe('text'); - expect(gs.value.name).toBe('num'); + const local = new LocalScope(reg, { K: reg.text(), V: reg.num() }); + const gs = Bag.get(local)!; + // gs.key / gs.value are AliasTypes; resolved via local. + expect((gs.key as AliasType).simplify(local).name).toBe('text'); + expect((gs.value as AliasType).simplify(local).name).toBe('num'); }); - test('JSON round-trip preserves generic', () => { - const T = r.generic('T'); - const Holder = r.extend('object', { + test('JSON round-trip preserves generic placeholder', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Holder = reg.extend('obj', { name: 'Holder', generic: { T }, props: { item: { type: T } }, }); - r.register(Holder); + reg.register(Holder); - const NumHolder = Holder.bind({ T: r.num() }); - const json = NumHolder.toJSON(); - expect(json.generic?.T).toEqual({ name: 'num', options: undefined }); - expect(json.props?.item?.type).toEqual({ name: 'num', options: undefined }); + const json = Holder.toJSON(); + // `T` survives in the JSON as a bare-name AliasType ref. + expect(json.generic?.T).toEqual({ name: 'T' }); + expect(json.props?.item?.type).toEqual({ name: 'T' }); - const reparsed = r.parse(json) as Extension; - expect(reparsed.local.props!.item!.type.name).toBe('num'); + const reparsed = reg.parse(json) as Extension; + expect(reparsed.local.props!.item!.type).toBeInstanceOf(AliasType); + const local = new LocalScope(reg, { T: reg.num() }); + expect((reparsed.local.props!.item!.type as AliasType).simplify(local).name).toBe('num'); }); - test('bind substitutes inside props, accessible via props()', () => { - const T = r.generic('T'); - const Wrapper = r.extend('object', { + test('extra-scope inside props is visible via props()', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Wrapper = reg.extend('obj', { name: 'Wrapper', generic: { T }, props: { inside: { type: T } }, }); - r.register(Wrapper); + reg.register(Wrapper); - const StrWrap = Wrapper.bind({ T: r.text({ minLength: 1 }) }); - // Access via the merged props() surface — T is replaced by the text type - // with its options preserved. - const inside = StrWrap.prop('inside'); + const local = new LocalScope(reg, { T: reg.text({ minLength: 1 }) }); + const inside = Wrapper.prop('inside', local); expect(inside).toBeDefined(); - expect(inside!.type.name).toBe('text'); - const textOpts = (inside!.type.options as { minLength?: number }); - expect(textOpts.minLength).toBe(1); + // The captured Prop's type is AliasType('T'); resolution via local + // returns the bound text type with options preserved. + const resolved = (inside!.type as AliasType).simplify(local); + expect(resolved.name).toBe('text'); + expect((resolved.options as { minLength?: number }).minLength).toBe(1); }); }); diff --git a/packages/gin/src/__tests__/extension.test.ts b/packages/gin/src/__tests__/extension.test.ts index 2219b568..6f8cf4bc 100644 --- a/packages/gin/src/__tests__/extension.test.ts +++ b/packages/gin/src/__tests__/extension.test.ts @@ -54,7 +54,7 @@ describe('Extension', () => { test('auto-Extension: object.props is native (no wrap)', () => { const r = createRegistry(); const json = { - name: 'object', + name: 'obj', props: { x: { type: { name: 'num' } } }, }; const back = r.parse(json); @@ -64,8 +64,8 @@ describe('Extension', () => { test('auto-Extension: fn.call is native (no wrap)', () => { const r = createRegistry(); const json = { - name: 'function', - call: { args: { name: 'object' }, returns: { name: 'num' } }, + name: 'fn', + call: { args: { name: 'obj' }, returns: { name: 'num' } }, }; const back = r.parse(json); expect(back).not.toBeInstanceOf(Extension); @@ -75,9 +75,9 @@ describe('Extension', () => { // obj consumes props but not init; adding init should trigger wrap. const r = createRegistry(); const json = { - name: 'object', + name: 'obj', props: { x: { type: { name: 'num' } } }, - init: { args: { name: 'object' }, run: { kind: 'native', id: 'foo' } }, + init: { args: { name: 'obj' }, run: { kind: 'native', id: 'foo' } }, }; const back = r.parse(json); expect(back).toBeInstanceOf(Extension); diff --git a/packages/gin/src/__tests__/fn.test.ts b/packages/gin/src/__tests__/fn.test.ts index f6ec5a1c..4c986d6f 100644 --- a/packages/gin/src/__tests__/fn.test.ts +++ b/packages/gin/src/__tests__/fn.test.ts @@ -8,7 +8,7 @@ describe('FnType', () => { test('builder with args/returns', () => { const t = r.fn(r.obj({ x: { type: r.num() } }), r.num()) as FnType; expect(t).toBeInstanceOf(FnType); - expect(t.call()?.args.name).toBe('object'); + expect(t.call()?.args.name).toBe('obj'); expect(t.call()?.returns?.name).toBe('num'); }); @@ -41,8 +41,8 @@ describe('FnType', () => { test('call is natively consumed → no auto-Extension', () => { const json = { - name: 'function', - call: { args: { name: 'object' }, returns: { name: 'num' } }, + name: 'fn', + call: { args: { name: 'obj' }, returns: { name: 'num' } }, }; const back = r.parse(json); expect(back).toBeInstanceOf(FnType); diff --git a/packages/gin/src/__tests__/gaps-analysis.test.ts b/packages/gin/src/__tests__/gaps-analysis.test.ts index 540a6ffb..af21cb2c 100644 --- a/packages/gin/src/__tests__/gaps-analysis.test.ts +++ b/packages/gin/src/__tests__/gaps-analysis.test.ts @@ -12,10 +12,10 @@ describe('Engine.typeOf', () => { test('lambda returns the declared fn type', () => { const t = e.typeOf({ kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } }, body: { kind: 'new', type: { name: 'text' }, value: 'hi' }, } as any); - expect(t.name).toBe('function'); + expect(t.name).toBe('fn'); }); test('block returns last line type', () => { @@ -77,7 +77,7 @@ describe('Engine.typeOf', () => { const t = e.typeOf({ kind: 'template', template: { kind: 'new', type: { name: 'text' }, value: '' }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, } as any); expect(t.name).toBe('text'); }); diff --git a/packages/gin/src/__tests__/gaps-generic.test.ts b/packages/gin/src/__tests__/gaps-generic.test.ts index a72f9a48..81f5fb9a 100644 --- a/packages/gin/src/__tests__/gaps-generic.test.ts +++ b/packages/gin/src/__tests__/gaps-generic.test.ts @@ -10,8 +10,8 @@ describe('PathCall.generic — explicit generic bindings', () => { // Build identity as a standalone fn typed against T. const identity = r.fn( - r.obj({ x: { type: r.generic('T') } }), - r.generic('T'), + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), ); // Run typeOf on a call with explicit generic binding; the returns diff --git a/packages/gin/src/__tests__/gaps-parallel.test.ts b/packages/gin/src/__tests__/gaps-parallel.test.ts index e9e212b4..a2d05141 100644 --- a/packages/gin/src/__tests__/gaps-parallel.test.ts +++ b/packages/gin/src/__tests__/gaps-parallel.test.ts @@ -107,3 +107,165 @@ describe('Loop.parallel — concurrency + rate', () => { expect(v.raw).toBe(5); }); }); + +/** + * Empirical concurrency tests — bodies that actually take time, with + * a probe native that records max in-flight count and total wall time. + * Asserts what the parallel orchestration in `LoopExpr.evaluate` + * actually does: with `concurrent: N`, up to N bodies run at once; + * with `rate: ms`, starts are paced; sequential mode never overlaps. + * + * The probe native blocks on `setTimeout(50ms)` and bumps a shared + * counter — same trick a fans-out HTTP client would exercise. Because + * the work is real wall-clock time, the timing assertions have a + * generous lower bound (parallelism MUST cut sequential time roughly + * by N) and a loose upper bound (CI variance is real). + */ +describe('LoopExpr.parallel — empirical concurrency', () => { + function setupProbe(): { + register: (e: import('../index').Engine) => void; + maxInFlight: () => number; + totalCalls: () => number; + reset: () => void; + } { + let inFlight = 0; + let max = 0; + let total = 0; + return { + register(e) { + e.registry.setNative('test.busy', async (_scope, reg) => { + inFlight++; + if (inFlight > max) max = inFlight; + total++; + // 50ms is enough overlap to be measurable across CI without + // making a 4-iteration test painfully slow. + await new Promise((r) => setTimeout(r, 50)); + inFlight--; + return reg.void().parse(undefined); + }); + }, + maxInFlight: () => max, + totalCalls: () => total, + reset: () => { inFlight = 0; max = 0; total = 0; }, + }; + } + + test('sequential loop never overlaps — max in-flight = 1', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3, 4] }, + // No `parallel` field → sequential path. Each body awaits + // before the next yield — so test.busy never overlaps. + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(4); + expect(probe.maxInFlight()).toBe(1); + // 4 × 50ms = 200ms minimum. Lower bound generous to absorb timer slop. + expect(elapsed).toBeGreaterThanOrEqual(180); + }); + + test('parallel concurrent=4 over 4 items — all 4 in-flight simultaneously', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3, 4] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 4 } }, + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(4); + // Concurrency upper bound = 4 — the actual peak should reach 4. + expect(probe.maxInFlight()).toBe(4); + // 4 bodies of 50ms running fully in parallel = ~50ms total. Allow + // up to 150ms before flagging — anything close to 200ms means the + // pool serialised them. + expect(elapsed).toBeLessThan(150); + }); + + test('parallel concurrent=2 over 6 items — peak in-flight clamps at 2', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3, 4, 5, 6] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(6); + // The pool caps active tasks at 2 — should never exceed that. + expect(probe.maxInFlight()).toBe(2); + // 6 bodies of 50ms with concurrency 2 = 3 batches × 50ms = ~150ms. + // Lower bound 130ms (allow timer slop), upper bound 250ms (catch + // accidental serialisation = 300ms). + expect(elapsed).toBeGreaterThanOrEqual(130); + expect(elapsed).toBeLessThan(250); + }); + + test('parallel rate=80ms paces iteration starts even at high concurrency', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3] }, + // Concurrency unbounded, but every start is at least 80ms + // after the previous. Three iterations → ~160ms wall time + // (start gaps) + 50ms body for the last one ≈ 210ms+. + parallel: { rate: { kind: 'new', type: { name: 'duration' }, value: { ms: 80 } } }, + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(3); + // First start: ~0ms. Second: ~80ms. Third: ~160ms. Last body + // finishes ~50ms after that → ≥ 210ms. + expect(elapsed).toBeGreaterThanOrEqual(180); + }); +}); diff --git a/packages/gin/src/__tests__/gaps-satisfies.test.ts b/packages/gin/src/__tests__/gaps-satisfies.test.ts index f2b1084a..fe8d8c83 100644 --- a/packages/gin/src/__tests__/gaps-satisfies.test.ts +++ b/packages/gin/src/__tests__/gaps-satisfies.test.ts @@ -4,7 +4,7 @@ import { createRegistry } from '../index'; describe('satisfies enforcement', () => { test('claimed interface not found → error', () => { const r = createRegistry(); - expect(() => r.parse({ name: 'num', satisfies: ['missing-iface'] })).toThrow(/unknown interface/); + expect(() => r.parse({ name: 'num', satisfies: ['missing_iface'] })).toThrow(/unknown interface/); }); test('structural mismatch → error', () => { @@ -14,9 +14,9 @@ describe('satisfies enforcement', () => { props: { fictional: { type: { name: 'any' } } }, }); // Give it a lookup name by wrapping in a named Extension. - const namedIface = r.extend(iface, { name: 'fictional-iface' }); + const namedIface = r.extend(iface, { name: 'fictional_iface' }); r.register(namedIface); - expect(() => r.parse({ name: 'num', satisfies: ['fictional-iface'] })).toThrow(/does not structurally match/); + expect(() => r.parse({ name: 'num', satisfies: ['fictional_iface'] })).toThrow(/does not structurally match/); }); test('structurally satisfying types pass', () => { @@ -24,12 +24,12 @@ describe('satisfies enforcement', () => { // Any interface whose requirements num already meets (e.g., has eq). const iface = r.iface({ props: { - eq: { type: { name: 'function', call: { args: { name: 'object', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, + eq: { type: { name: 'fn', call: { args: { name: 'obj', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, }, }); - const named = r.extend(iface, { name: 'has-eq' }); + const named = r.extend(iface, { name: 'has_eq' }); r.register(named); - expect(() => r.parse({ name: 'num', satisfies: ['has-eq'] })).not.toThrow(); + expect(() => r.parse({ name: 'num', satisfies: ['has_eq'] })).not.toThrow(); }); }); @@ -39,12 +39,12 @@ describe('Registry.getTypesFor', () => { // Build an interface requiring a `toText` method. const iface = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); - const named = r.extend(iface, { name: 'has-toText' }); + const named = r.extend(iface, { name: 'has_toText' }); r.register(named); - const matches = r.getTypesFor('has-toText'); + const matches = r.getTypesFor('has_toText'); // At minimum: bool, num, any, void, null, not — all declare toText. const names = matches.map((t) => t.name); expect(names).toContain('num'); @@ -53,6 +53,6 @@ describe('Registry.getTypesFor', () => { test('returns empty for unknown interface', () => { const r = createRegistry(); - expect(r.getTypesFor('not-a-real-iface')).toEqual([]); + expect(r.getTypesFor('not_a_real_iface')).toEqual([]); }); }); diff --git a/packages/gin/src/__tests__/generic-constraints.test.ts b/packages/gin/src/__tests__/generic-constraints.test.ts new file mode 100644 index 00000000..eb3d0f93 --- /dev/null +++ b/packages/gin/src/__tests__/generic-constraints.test.ts @@ -0,0 +1,209 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine } from '../index'; +import { CallStep } from '../path'; +import { AliasType } from '../types/alias'; + +/** + * Constraints on generics — declared via `generic: { R: }`. + * The constraint is the type a call-site binding for R must satisfy + * (`constraint.compatible(binding) === true`); it is NOT a default + * resolution. R itself stays an unresolved AliasType placeholder until + * a call-site binding layers it into the scope. + * + * Semantics: + * - Bare `{name: 'R'}` inside the signature parses as AliasType('R') + * and resolves only through caller-supplied scope, never to its + * constraint. + * - `CallStep.callSiteScope(calledType)` validates each binding + * against the declared constraint and throws on violation. + * - `R: alias('R')` is the canonical "unconstrained" declaration — + * no satisfies check is run for that form. (`any` works too — + * compatible() is permissive — but the self-ref form is what the + * declaration-site reads as "no constraint".) + */ +describe('generic constraints', () => { + test('unconstrained generic — any binding accepted', () => { + const r = createRegistry(); + // identity({x: T}): T — declared with `any` constraint. + const identity = r.fn( + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), + undefined, + { T: r.any() }, + ); + + const stepNum = new CallStep({}, { T: { name: 'num' } }); + const stepText = new CallStep({}, { T: { name: 'text' } }); + + expect(() => stepNum.callSiteScope(identity)).not.toThrow(); + expect(() => stepText.callSiteScope(identity)).not.toThrow(); + }); + + test('union constraint — only members of the union are accepted', () => { + const r = createRegistry(); + // describe(...) — like fns.llm's R constraint. + const describer = r.fn( + r.obj({}), + r.alias('R'), + undefined, + { R: r.or([r.text(), r.obj({})]) }, + ); + + // Accepted: text fits the or constraint. + expect(() => + new CallStep({}, { R: { name: 'text' } }).callSiteScope(describer), + ).not.toThrow(); + // Accepted: obj fits. + expect(() => + new CallStep({}, { R: { name: 'obj', props: { x: { type: { name: 'num' } } } } }).callSiteScope(describer), + ).not.toThrow(); + // Rejected: num doesn't satisfy text | obj. + expect(() => + new CallStep({}, { R: { name: 'num' } }).callSiteScope(describer), + ).toThrow(/generic 'R' binding .* does not satisfy constraint/); + }); + + test('interface constraint — structural satisfaction at binding time', () => { + // Interface declaring a single method `length: num` (read as a prop + // returning num — text and list both expose it; num and bool do not). + // Used to demonstrate that the satisfies check is structural via + // `iface.compatible(binding)`. + const r = createRegistry(); + const Sized = r.iface({ + props: { length: { type: { name: 'num' } } }, + }); + + // measure({x: T}): num + const measure = r.fn( + r.obj({ x: { type: r.alias('T') } }), + r.num(), + undefined, + { T: Sized }, + ); + + // text has `length: num` → satisfies Sized. + expect(() => + new CallStep({}, { T: { name: 'text' } }).callSiteScope(measure), + ).not.toThrow(); + + // list has `length: num` → satisfies. + expect(() => + new CallStep({}, { T: { name: 'list', generic: { V: { name: 'num' } } } }).callSiteScope(measure), + ).not.toThrow(); + + // num has no `length` prop → does NOT satisfy. + expect(() => + new CallStep({}, { T: { name: 'num' } }).callSiteScope(measure), + ).toThrow(/generic 'T' binding 'num' does not satisfy constraint/); + + // bool has no `length` prop → does NOT satisfy. + expect(() => + new CallStep({}, { T: { name: 'bool' } }).callSiteScope(measure), + ).toThrow(/generic 'T' binding 'bool' does not satisfy constraint/); + }); + + test('self-referencing constraint (R: alias R) is unconstrained', () => { + // `{ R: alias('R') }` is a self-reference — the constraint resolves + // to itself, declaring "this generic has no real constraint". The + // satisfies check is skipped for this form so any binding is accepted. + const r = createRegistry(); + const identity = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.alias('R') }, + ); + + expect(() => + new CallStep({}, { R: { name: 'num' } }).callSiteScope(identity), + ).not.toThrow(); + expect(() => + new CallStep({}, { R: { name: 'text' } }).callSiteScope(identity), + ).not.toThrow(); + expect(() => + new CallStep({}, { R: { name: 'bool' } }).callSiteScope(identity), + ).not.toThrow(); + }); + + test('constraint is not a default — unbound R stays a placeholder', () => { + // The constraint type is stored on `fnType.generic[k]` but is NOT + // bound into the captured scope. Bare `alias('R')` inside the + // signature stays unresolved (AliasType placeholder); only call- + // site bindings provide concrete resolution. + const r = createRegistry(); + const fn = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.text() }, // constraint, not default + ); + + // Without a call-site binding, the captured fn scope does NOT + // resolve R to text. The args type's `x` field is AliasType('R') + // and stays so when accessed via the fn's own scope. + const argsType = fn.call()!.args; + const xField = (argsType as unknown as { fields: Record }).fields.x; + expect(xField.type).toBeInstanceOf(AliasType); + expect((xField.type as AliasType).options.name).toBe('R'); + + // The constraint IS retained in `fn.generic` for later validation. + expect(fn.generic.R!.name).toBe('text'); + }); + + test('runtime call: binding satisfying the constraint resolves the return type', async () => { + // End-to-end: build a generic identity-like fn with a `text|obj` + // constraint, invoke at the engine level with an explicit binding. + // The path's typeOf reflects the bound R; the binding succeeds. + const r = createRegistry(); + const e = new Engine(r); + + const identity = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.or([r.text(), r.obj({})]) }, + ); + + const expr = { + kind: 'get', + path: [ + { prop: 'f' }, + { + args: { x: { kind: 'new', type: { name: 'text' }, value: 'hi' } }, + generic: { R: { name: 'text' } }, + }, + ], + } as const; + + const scope = new Map([['f', identity]]); + expect(e.typeOf(expr, scope).name).toBe('text'); + }); + + test('runtime call: binding violating the constraint throws', () => { + const r = createRegistry(); + const e = new Engine(r); + + const identity = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.or([r.text(), r.obj({})]) }, + ); + + // bool doesn't satisfy text|obj — typeOf walks callSiteScope which + // throws on the satisfies failure. + const expr = { + kind: 'get', + path: [ + { prop: 'f' }, + { + args: { x: { kind: 'new', type: { name: 'bool' }, value: true } }, + generic: { R: { name: 'bool' } }, + }, + ], + } as const; + + const scope = new Map([['f', identity]]); + expect(() => e.typeOf(expr, scope)).toThrow(/generic 'R' binding 'bool' does not satisfy/); + }); +}); diff --git a/packages/gin/src/__tests__/generic-methods.test.ts b/packages/gin/src/__tests__/generic-methods.test.ts index 67df667a..514b7e15 100644 --- a/packages/gin/src/__tests__/generic-methods.test.ts +++ b/packages/gin/src/__tests__/generic-methods.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; import { FnType } from '../types/fn'; -import { GenericType } from '../types/generic'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; /** * Method-level generics: `r.method(args, returns, id, { generic: {...} })` @@ -13,7 +14,7 @@ import { GenericType } from '../types/generic'; describe('method-level generics on fn/method', () => { test('r.fn accepts a generic map and stores it on FnType.generic', () => { const r = createRegistry(); - const f = r.fn(r.obj({}), r.generic('T'), undefined, { T: r.any() }) as FnType; + const f = r.fn(r.obj({}), r.alias('T'), undefined, { T: r.any() }) as FnType; expect(f).toBeInstanceOf(FnType); expect(Object.keys(f.generic)).toEqual(['T']); expect(f.generic.T!.name).toBe('any'); @@ -22,8 +23,8 @@ describe('method-level generics on fn/method', () => { test('r.method forwards options.generic into the fn type', () => { const r = createRegistry(); const prop = r.method( - { other: r.generic('T') }, - r.generic('T'), + { other: r.alias('T') }, + r.alias('T'), 'example.op', { generic: { T: r.any() } }, ); @@ -42,8 +43,8 @@ describe('method-level generics on fn/method', () => { test('toCode renders method generics as prefix on fn signatures', () => { const r = createRegistry(); const f = r.fn( - r.obj({ x: { type: r.generic('T') } }), - r.generic('T'), + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), undefined, { T: r.any() }, ); @@ -53,7 +54,7 @@ describe('method-level generics on fn/method', () => { test('toCode with bound generic renders ', () => { const r = createRegistry(); // Constraint: T extends num. - const f = r.fn(r.obj({ x: { type: r.generic('T') } }), r.generic('T'), undefined, { + const f = r.fn(r.obj({ x: { type: r.alias('T') } }), r.alias('T'), undefined, { T: r.num(), }); expect(f.toCode()).toBe('(x: T): T'); @@ -62,8 +63,8 @@ describe('method-level generics on fn/method', () => { test('toCode with multiple generics — mix of bound and unbound', () => { const r = createRegistry(); const f = r.fn( - r.obj({ a: { type: r.generic('A') }, b: { type: r.generic('B') } }), - r.generic('A'), + r.obj({ a: { type: r.alias('A') }, b: { type: r.alias('B') } }), + r.alias('A'), undefined, { A: r.any(), B: r.num() }, ); @@ -81,8 +82,8 @@ describe('FnType.generic — JSON round-trip', () => { test('toJSON serializes generic map; parse reconstructs it', () => { const r = createRegistry(); const f = r.fn( - r.obj({ x: { type: r.generic('T') } }), - r.generic('T'), + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), undefined, { T: r.num() }, ); @@ -104,19 +105,19 @@ describe('FnType.generic — JSON round-trip', () => { test('round-trip preserves generic placeholders inside args/returns', () => { const r = createRegistry(); const f = r.fn( - r.obj({ fn: { type: r.fn(r.obj({ v: { type: r.generic('T') } }), r.bool()) } }), - r.generic('T'), + r.obj({ fn: { type: r.fn(r.obj({ v: { type: r.alias('T') } }), r.bool()) } }), + r.alias('T'), undefined, { T: r.any() }, ); const back = r.parse(f.toJSON()) as FnType; expect(Object.keys(back.generic)).toEqual(['T']); - // Inner fn arg type should still be a GenericType named 'T'. + // Inner fn arg type should still be a AliasType named 'T'. const innerFnType = (back.call().args as unknown as { fields?: Record }) .fields?.fn?.type as FnType; const innerArgs = innerFnType.call().args as unknown as { fields?: Record }; - const vType = innerArgs.fields?.v?.type as GenericType; - expect(vType).toBeInstanceOf(GenericType); + const vType = innerArgs.fields?.v?.type as AliasType; + expect(vType).toBeInstanceOf(AliasType); expect(vType.options.name).toBe('T'); }); }); @@ -124,7 +125,7 @@ describe('FnType.generic — JSON round-trip', () => { describe('toCodeDefinition — method-level generics', () => { test('list.map shows (...): list — R is method-only, not inherited', () => { const r = createRegistry(); - const listT = r.list(r.generic('V')); + const listT = r.list(r.alias('V')); const def = listT.toCodeDefinition(); expect(def).toContain('type list'); expect(def).toContain('map(fn: (value: V, index: num): R): list'); @@ -132,7 +133,7 @@ describe('toCodeDefinition — method-level generics', () => { test('filter inherits V but introduces no new generic → no <> suffix', () => { const r = createRegistry(); - const listT = r.list(r.generic('V')); + const listT = r.list(r.alias('V')); const def = listT.toCodeDefinition(); // filter uses only V (from the outer type), not R — so no method-level <>. expect(def).toMatch(/filter\(fn: \(value: V, index: num\): bool\): list/); @@ -144,8 +145,8 @@ describe('toCodeDefinition — method-level generics', () => { // Type declares V; method redundantly declares V as well — the method // generic list should NOT include V (since it's inherited from outer). const fn = r.fn( - r.obj({ x: { type: r.generic('V') } }), - r.generic('V'), + r.obj({ x: { type: r.alias('V') } }), + r.alias('V'), undefined, { V: r.any() }, ); @@ -161,34 +162,43 @@ describe('toCodeDefinition — method-level generics', () => { }); }); -describe('runtime behavior — CallStep.bindGeneric', () => { - test('fn type.bind substitutes method generics through nested positions', () => { +describe('runtime behavior — call-site generic resolution via TypeScope', () => { + test('fn signature with extra-scope R=num resolves R through nested positions', () => { const r = createRegistry(); const f = r.fn( - r.obj({ v: { type: r.generic('R') } }), - r.list(r.generic('R')), + r.obj({ v: { type: r.alias('R') } }), + r.list(r.alias('R')), undefined, { R: r.any() }, ); - const bound = f.bind({ R: r.num() }) as FnType; - // After binding, R should be substituted everywhere. - const args = bound.call().args as unknown as { fields?: Record }; - expect(args.fields?.v?.type.name).toBe('num'); - expect(bound.call().returns?.name).toBe('list'); - }); - - test('bind with missing key leaves unbound placeholders intact', () => { + const local = new LocalScope(r, { R: r.num() }); + // The fn type itself is unchanged — call() returns the same Call. + // But when accessed with `local`, AliasTypes inside resolve. + const args = f.call(local).args as unknown as { fields?: Record }; + const vType = args.fields!.v!.type; + expect(vType.simplify(local).name).toBe('num'); + // Returns: list; the list itself stays a ListType, but its + // item is an AliasType resolving via local to num. + const ret = f.call(local).returns!; + expect(ret.name).toBe('list'); + const item = (ret as unknown as { item: AliasType }).item; + expect(item.simplify(local).name).toBe('num'); + }); + + test('extra-scope without matching name leaves placeholders unresolved', () => { const r = createRegistry(); const f = r.fn( - r.obj({ v: { type: r.generic('R') } }), - r.generic('R'), + r.obj({ v: { type: r.alias('R') } }), + r.alias('R'), undefined, { R: r.any() }, ); - const bound = f.bind({ NOT_R: r.num() }) as FnType; - const args = bound.call().args as unknown as { fields?: Record }; - expect(args.fields?.v?.type).toBeInstanceOf(GenericType); - expect(args.fields?.v?.type.options.name).toBe('R'); + const local = new LocalScope(r, { NOT_R: r.num() }); + const args = f.call(local).args as unknown as { fields?: Record }; + expect(args.fields!.v!.type).toBeInstanceOf(AliasType); + expect(args.fields!.v!.type.options.name).toBe('R'); + // simplify(local) returns self (R isn't bound in local). + expect(args.fields!.v!.type.simplify(local)).toBe(args.fields!.v!.type); }); }); @@ -197,7 +207,7 @@ describe('invalid / edge cases', () => { const r = createRegistry(); // A method's generic R is a type-level placeholder — at runtime, before // binding, it's indistinguishable from `any`: everything is valid. - const placeholder = r.generic('R'); + const placeholder = r.alias('R'); expect(placeholder.valid(5)).toBe(true); expect(placeholder.valid('hi')).toBe(true); expect(placeholder.valid(null)).toBe(true); @@ -206,7 +216,7 @@ describe('invalid / edge cases', () => { test('method generic bound to a constraint still renders correctly', () => { const r = createRegistry(); const prop = r.method( - { key: r.generic('K') }, + { key: r.alias('K') }, r.bool(), 'example.has', { generic: { K: r.text() } }, @@ -219,7 +229,7 @@ describe('invalid / edge cases', () => { // Build a bare FnType whose generic list happens to contain V — when // placed inside a list's definition, V would be filtered out. // Here we just verify the fn itself (standalone) shows . - const f = r.fn(r.obj({ x: { type: r.generic('V') } }), r.generic('V'), undefined, { + const f = r.fn(r.obj({ x: { type: r.alias('V') } }), r.alias('V'), undefined, { V: r.any(), }); expect(f.toCode()).toContain(''); diff --git a/packages/gin/src/__tests__/generic.test.ts b/packages/gin/src/__tests__/generic.test.ts index a6ba1ae8..4568f4f0 100644 --- a/packages/gin/src/__tests__/generic.test.ts +++ b/packages/gin/src/__tests__/generic.test.ts @@ -1,54 +1,75 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; -import { GenericType } from '../types/generic'; -import { NumType } from '../types/num'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; -describe('GenericType', () => { +/** + * Generic-parameter behavior under the unified AliasType + scope-based + * resolution. A bare `r.alias('V')` resolves through its captured + * scope; an extra TypeScope can be passed at access time to override + * the captured layer (this is how call-site `` bindings reach + * AliasTypes inside a fn signature without rebuilding the type tree). + * No `Type.bind` / `substitute` API any more. + */ +describe('AliasType (generic flavor)', () => { const r = createRegistry(); test('builder stores the param name', () => { - const g = r.generic('V') as GenericType; - expect(g).toBeInstanceOf(GenericType); + const g = r.alias('V') as AliasType; + expect(g).toBeInstanceOf(AliasType); expect(g.options.name).toBe('V'); }); test('valid accepts anything before binding', () => { - const g = r.generic('V'); + const g = r.alias('V'); expect(g.valid(5)).toBe(true); expect(g.valid('x')).toBe(true); }); test('compatible is true before binding', () => { - expect(r.generic('V').compatible(r.num())).toBe(true); + expect(r.alias('V').compatible(r.num())).toBe(true); }); test('flexible is true', () => { - expect(r.generic('V').flexible()).toBe(true); + expect(r.alias('V').flexible()).toBe(true); }); - test('bind resolves against matching name', () => { - const g = r.generic('V'); - const bound = g.bind({ V: r.num() }); - expect(bound).toBeInstanceOf(NumType); + test('extra-scope resolution: V → num via passed scope', () => { + // The captured scope (registry root) doesn't know V. But when we + // pass an extra LocalScope binding V to num, the AliasType's + // value-side ops delegate to num. + const g = r.alias('V'); + const local = new LocalScope(r, { V: r.num() }); + expect(g.valid(5, local)).toBe(true); + expect(g.valid('x', local)).toBe(false); // num rejects strings + expect(g.simplify(local).name).toBe('num'); // collapses to the bound type }); - test('bind keeps self when no matching binding', () => { - const g = r.generic('V'); - const bound = g.bind({ X: r.num() }); - expect(bound).toBeInstanceOf(GenericType); + test('extra-scope without matching name is a no-op', () => { + const g = r.alias('V'); + const local = new LocalScope(r, { X: r.num() }); + expect(g.simplify(local)).toBe(g); // unresolved → self }); - test('bind substitutes through a list type', () => { - const listGeneric = r.list(r.generic('V')); - const bound = listGeneric.bind({ V: r.num() }); - // after binding, the list's item should be num - expect((bound as any).item?.name).toBe('num'); + test('list resolves V via extra scope on parse', () => { + // The list type contains AliasType('V') captured at registry root. + // Parsing a list of 5s should validate when V is bound to num. + const list = r.list(r.alias('V')); + const local = new LocalScope(r, { V: r.num() }); + const v = list.parse([1, 2, 3], local); + expect(v.raw.length).toBe(3); + expect(v.raw[0]!.type.name).toBe('alias'); // alias preserved + expect((v.raw[0]!.type as AliasType).simplify(local).name).toBe('num'); }); test('encode + parse roundtrip', () => { - const t = r.generic('T'); - const back = r.parse(t.toJSON()) as GenericType; - expect(back).toBeInstanceOf(GenericType); + const t = r.alias('T'); + const json = t.toJSON(); + expect(json).toEqual({ name: 'T' }); + // Bare-name 'T' is unknown to the root registry — re-parsed in + // root scope it stays an AliasType (forward-ref / placeholder). + const back = r.parse(json) as AliasType; + expect(back).toBeInstanceOf(AliasType); expect(back.options.name).toBe('T'); }); }); diff --git a/packages/gin/src/__tests__/ginny-docs.test.ts b/packages/gin/src/__tests__/ginny-docs.test.ts index 9b24abd5..ddf15434 100644 --- a/packages/gin/src/__tests__/ginny-docs.test.ts +++ b/packages/gin/src/__tests__/ginny-docs.test.ts @@ -15,7 +15,9 @@ function placeholderize(r: ReturnType, cls: { NAME: strin const keys = Object.keys(canonical.generic); if (keys.length === 0) return canonical; const genericDef: Record = {}; - for (const k of keys) genericDef[k] = { name: 'generic', options: { name: k } }; + // Bare-name shape: `{name: 'V'}` parses to an AliasType('V') in + // the registry-root scope (unresolved → universal placeholder). + for (const k of keys) genericDef[k] = { name: k }; try { return cls.from({ name: cls.NAME, generic: genericDef } as TypeDef, r); } catch { return canonical; } } diff --git a/packages/gin/src/__tests__/iface.test.ts b/packages/gin/src/__tests__/iface.test.ts index 30dd6c6d..aedb54eb 100644 --- a/packages/gin/src/__tests__/iface.test.ts +++ b/packages/gin/src/__tests__/iface.test.ts @@ -8,7 +8,7 @@ describe('IfaceType', () => { test('builder accepts a spec', () => { const i = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); expect(i).toBeInstanceOf(IfaceType); @@ -17,7 +17,7 @@ describe('IfaceType', () => { test('compatible: type that has matching props satisfies interface', () => { const i = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); // num has toText — should satisfy diff --git a/packages/gin/src/__tests__/list.test.ts b/packages/gin/src/__tests__/list.test.ts index 98401942..1ed0e70e 100644 --- a/packages/gin/src/__tests__/list.test.ts +++ b/packages/gin/src/__tests__/list.test.ts @@ -80,7 +80,7 @@ describe('ListType', () => { test('at method returns optional V', () => { const p = r.list(r.num()).props(); - expect(p.at?.type.name).toBe('function'); + expect(p.at?.type.name).toBe('fn'); }); test('encode + parse roundtrip', () => { diff --git a/packages/gin/src/__tests__/loop-coverage.test.ts b/packages/gin/src/__tests__/loop-coverage.test.ts new file mode 100644 index 00000000..c519a551 --- /dev/null +++ b/packages/gin/src/__tests__/loop-coverage.test.ts @@ -0,0 +1,389 @@ +import { describe, test, expect } from 'vitest'; +import { primitives } from './_utils'; +import { createRegistry, Engine } from '../index'; + +/** + * Loop coverage for every type that exposes `get().loop` (or + * `get().loopDynamic`). Each type gets: + * + * - a sequential test verifying iteration ORDER and the per-step + * `key` / `value` bindings + * - a parallel test verifying every iteration executes when the + * parallel options are set (where parallel is meaningful) + * + * `list` and `num` are covered in `exprs-loop-flow.test.ts` / + * `gaps-parallel.test.ts`; `bool` (while-loop semantics) lives in + * `loop-while-bool.test.ts`. This file fills the gaps for `map`, + * `obj`, and `text`. + */ +describe('LoopExpr — coverage for map / obj / text', () => { + describe('map', () => { + test('sequential iteration yields every entry', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'm', + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } }, + value: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + { key: 'c', value: 3 }, + ], + }, + }, + { name: 'sum', value: { kind: 'new', type: { name: 'num' }, value: 0 } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'm' }] }, + body: { + kind: 'set', + path: [{ prop: 'sum' }], + value: { + kind: 'get', + path: [ + { prop: 'sum' }, { prop: 'add' }, + { args: { other: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'sum' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(1 + 2 + 3); + }); + + test('keys come through as the map key type', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'm', + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } }, + value: [ + { key: 'x', value: 10 }, + { key: 'y', value: 20 }, + ], + }, + }, + { name: 'collected', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'm' }] }, + body: { + kind: 'get', + path: [ + { prop: 'collected' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'key' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'collected' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v).sort()).toEqual(['x', 'y']); + }); + + test('parallel concurrent=2 still iterates every entry', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'm', + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } }, + value: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + { key: 'c', value: 3 }, + { key: 'd', value: 4 }, + ], + }, + }, + { name: 'out', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'm' }] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { + kind: 'get', + path: [ + { prop: 'out' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'out' }, { prop: 'length' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(4); + }); + }); + + describe('obj', () => { + test('sequential iteration walks every field as (name, value)', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'o', + value: { + kind: 'new', + type: { + name: 'obj', + props: { + a: { type: { name: 'num' } }, + b: { type: { name: 'num' } }, + c: { type: { name: 'num' } }, + }, + }, + value: { a: 10, b: 20, c: 30 }, + }, + }, + { name: 'sum', value: { kind: 'new', type: { name: 'num' }, value: 0 } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'o' }] }, + body: { + kind: 'set', + path: [{ prop: 'sum' }], + value: { + kind: 'get', + path: [ + { prop: 'sum' }, { prop: 'add' }, + { args: { other: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'sum' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(60); + }); + + test('iteration keys are the field names', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'o', + value: { + kind: 'new', + type: { + name: 'obj', + props: { + alpha: { type: { name: 'num' } }, + beta: { type: { name: 'num' } }, + }, + }, + value: { alpha: 1, beta: 2 }, + }, + }, + { name: 'names', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'o' }] }, + body: { + kind: 'get', + path: [ + { prop: 'names' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'key' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'names' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v).sort()).toEqual(['alpha', 'beta']); + }); + + test('parallel concurrent=2 over an obj still hits every field', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'o', + value: { + kind: 'new', + type: { + name: 'obj', + props: { + a: { type: { name: 'num' } }, + b: { type: { name: 'num' } }, + c: { type: { name: 'num' } }, + }, + }, + value: { a: 1, b: 2, c: 3 }, + }, + }, + { name: 'out', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'o' }] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { + kind: 'get', + path: [ + { prop: 'out' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'out' }, { prop: 'length' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(3); + }); + }); + + describe('text', () => { + test('sequential iteration yields each character in order', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { name: 's', value: { kind: 'new', type: { name: 'text' }, value: 'abc' } }, + { name: 'collected', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 's' }] }, + body: { + kind: 'get', + path: [ + { prop: 'collected' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'collected' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v)).toEqual(['a', 'b', 'c']); + }); + + test('iteration keys are 0-based indices', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { name: 's', value: { kind: 'new', type: { name: 'text' }, value: 'xy' } }, + { name: 'indices', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 's' }] }, + body: { + kind: 'get', + path: [ + { prop: 'indices' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'key' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'indices' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v)).toEqual([0, 1]); + }); + + test('parallel concurrent=2 over text still iterates every character', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { name: 's', value: { kind: 'new', type: { name: 'text' }, value: 'abcde' } }, + { name: 'out', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 's' }] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { + kind: 'get', + path: [ + { prop: 'out' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'out' }, { prop: 'length' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(5); + }); + }); +}); diff --git a/packages/gin/src/__tests__/loop-while-bool.test.ts b/packages/gin/src/__tests__/loop-while-bool.test.ts new file mode 100644 index 00000000..95ef9a86 --- /dev/null +++ b/packages/gin/src/__tests__/loop-while-bool.test.ts @@ -0,0 +1,386 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, val } from '../index'; + +/** + * `LoopExpr` accepts a bool-typed `over` expression and re-evaluates + * it each iteration — true while-loop semantics. The loop continues + * while the value is `true` and exits when it flips to `false`. + * + * - The loop body sees `key` (num iteration index) and `value` + * (the bool's value, always `true` at body entry). + * - `flow:break` and `flow:continue` work as in any loop. + * - Static `validate()` no longer flags bool over as + * `loop.not-iterable`. Parallel options on a bool over flag as + * `loop.parallel.bool`. + */ + +const r = createRegistry(); +const e = new Engine(r); + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; +const boolLit = (b: boolean) => ({ kind: 'new', type: { name: 'bool' }, value: b }) as const; + +describe('LoopExpr — bool while-loop semantics', () => { + test('initial false → body runs zero times', async () => { + // Set a `ran` var to 1, loop should NOT execute, var stays 1. + const result = await e.run({ + kind: 'define', + vars: [{ name: 'ran', value: numLit(1) }], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: boolLit(false), + body: { + kind: 'set', + path: [{ prop: 'ran' }], + value: numLit(99), + }, + }, + { kind: 'get', path: [{ prop: 'ran' }] }, + ], + }, + }); + expect(result.raw).toBe(1); + }); + + test('expression re-evaluates each iteration: counts down to zero', async () => { + // Counter starts at 3; loop while counter > 0; body decrements. + // Should run 3 times (3, 2, 1) and exit when counter reaches 0. + const result = await e.run({ + kind: 'define', + vars: [{ name: 'counter', value: numLit(3) }], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + // counter > 0 — re-evaluated every iteration. + over: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'gt' }, + { args: { other: numLit(0) } }, + ], + }, + body: { + kind: 'set', + path: [{ prop: 'counter' }], + value: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'sub' }, + { args: { other: numLit(1) } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'counter' }] }, + ], + }, + }); + expect(result.raw).toBe(0); + }); + + test('break exits the loop early', async () => { + // Loop while true forever, break when key === 5. + const result = await e.run({ + kind: 'define', + vars: [{ name: 'last', value: numLit(-1) }], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: boolLit(true), + body: { + kind: 'block', + lines: [ + { + kind: 'set', + path: [{ prop: 'last' }], + value: { kind: 'get', path: [{ prop: 'key' }] }, + }, + { + kind: 'if', + ifs: [{ + condition: { + kind: 'get', + path: [ + { prop: 'key' }, { prop: 'gte' }, + { args: { other: numLit(5) } }, + ], + }, + body: { kind: 'flow', action: 'break' }, + }], + }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'last' }] }, + ], + }, + }); + expect(result.raw).toBe(5); + }); + + test('continue jumps to next iteration', async () => { + // Decrement counter, increment hits only on iterations where + // counter is still > 0. With continue at iteration 0 we still + // hit the counter mutation BEFORE the continue is reached, so + // verify the iteration index advances. + const result = await e.run({ + kind: 'define', + vars: [ + { name: 'counter', value: numLit(3) }, + { name: 'hits', value: numLit(0) }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'gt' }, + { args: { other: numLit(0) } }, + ], + }, + body: { + kind: 'block', + lines: [ + // Always decrement counter. + { + kind: 'set', + path: [{ prop: 'counter' }], + value: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'sub' }, + { args: { other: numLit(1) } }, + ], + }, + }, + // Skip the hits++ when key === 0 via continue. + { + kind: 'if', + ifs: [{ + condition: { + kind: 'get', + path: [ + { prop: 'key' }, { prop: 'eq' }, + { args: { other: numLit(0) } }, + ], + }, + body: { kind: 'flow', action: 'continue' }, + }], + }, + // Increment hits otherwise. + { + kind: 'set', + path: [{ prop: 'hits' }], + value: { + kind: 'get', + path: [ + { prop: 'hits' }, { prop: 'add' }, + { args: { other: numLit(1) } }, + ], + }, + }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'hits' }] }, + ], + }, + }); + // 3 iterations (counter 3→2→1→0). On iter 0 we continue (skip hits). + // On iter 1 and iter 2 we hit. So hits=2. + expect(result.raw).toBe(2); + }); + + test('binds key=num{whole, min:0} and value=bool in the body scope', async () => { + // Verify body sees correct types — read key + value, return them. + const result = await e.run({ + kind: 'define', + vars: [ + { name: 'idx', value: numLit(-1) }, + { name: 'lastV', value: { kind: 'new', type: { name: 'bool' }, value: false } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + // Run exactly one iteration. + over: { + kind: 'get', + path: [ + { prop: 'idx' }, { prop: 'lt' }, + { args: { other: numLit(0) } }, + ], + }, + body: { + kind: 'block', + lines: [ + { + kind: 'set', + path: [{ prop: 'idx' }], + value: { kind: 'get', path: [{ prop: 'key' }] }, + }, + { + kind: 'set', + path: [{ prop: 'lastV' }], + value: { kind: 'get', path: [{ prop: 'value' }] }, + }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'idx' }] }, + ], + }, + }); + // First iter has key=0; setting idx=0 makes the next over-eval + // false (idx < 0 → false); loop exits. So idx ends at 0. + expect(result.raw).toBe(0); + }); +}); + +describe('GetSet.loopDynamic — bool opts in via the flag', () => { + test('BoolType.get() returns a GetSet with loopDynamic: true and no `loop` ExprDef', () => { + const gs = r.bool().get(); + expect(gs).toBeDefined(); + expect(gs!.loopDynamic).toBe(true); + expect(gs!.loop).toBeUndefined(); + expect(gs!.key.name).toBe('num'); + expect(gs!.value.name).toBe('bool'); + }); + + test('list iterables remain static (loop ExprDef present, no loopDynamic)', () => { + const gs = r.list(r.num()).get(); + expect(gs).toBeDefined(); + expect(gs!.loop).toBeDefined(); + expect(gs!.loopDynamic).toBeFalsy(); + }); +}); + +describe('LoopExpr — validation accepts bool over', () => { + test('bool over does NOT flag loop.not-iterable', () => { + const probs = e.validate({ + kind: 'loop', + over: boolLit(true), + body: { kind: 'flow', action: 'break' }, + }); + expect(probs.list.some((p) => p.code === 'loop.not-iterable')).toBe(false); + }); + + test('parallel options on a dynamic (bool) loop are accepted', () => { + // Dynamic + parallel runs the body concurrently up to `concurrent` + // tasks; `over` is re-evaluated after each completion. No analyzer + // warning — both modes compose. + const probs = e.validate({ + kind: 'loop', + over: boolLit(true), + parallel: { concurrent: numLit(2) }, + body: { kind: 'flow', action: 'break' }, + }); + expect(probs.list.some((p) => p.code === 'loop.parallel.dynamic')).toBe(false); + }); + + test('dynamic + parallel: body runs concurrently up to `concurrent`', async () => { + // `over` flips false once the counter reaches 6. With concurrent=3, + // the test.busy probe should report 3 simultaneously in-flight at + // peak. Wall time should be roughly 2 batches × 50ms (≤ ~150ms), + // not 6 × 50ms = 300ms. + let inFlight = 0; + let max = 0; + const r2 = createRegistry(); + const e2 = new Engine(r2); + r2.setNative('test.busy', async (_scope, reg) => { + inFlight++; + if (inFlight > max) max = inFlight; + await new Promise((res) => setTimeout(res, 50)); + inFlight--; + return val(reg.void(), undefined); + }); + + const program = { + kind: 'define', + vars: [ + { name: 'count', value: { kind: 'new', type: { name: 'num' }, value: 0 } }, + ], + body: { + kind: 'loop', + // over = count.lt(6); re-evaluated after each task completes. + over: { + kind: 'get', + path: [ + { prop: 'count' }, { prop: 'lt' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 6 } } }, + ], + }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 3 } }, + body: { + kind: 'block', + lines: [ + // Spawn the busy probe AND increment count so over flips. + // The increment lands BEFORE busy resolves so subsequent + // re-evals see updated counter, but several tasks can be + // simultaneously waiting in busy. + { + kind: 'set', + path: [{ prop: 'count' }], + value: { + kind: 'get', + path: [ + { prop: 'count' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 1 } } }, + ], + }, + }, + { kind: 'native', id: 'test.busy' }, + ], + }, + }, + } as const; + + const start = Date.now(); + await e2.run(program); + const elapsed = Date.now() - start; + + // 6 iterations × 50ms with concurrency 3 = 2 batches × 50ms ≈ 100ms. + expect(max).toBeGreaterThanOrEqual(2); + expect(max).toBeLessThanOrEqual(3); + expect(elapsed).toBeLessThan(200); + }); + + test('non-iterable, non-bool over still flags loop.not-iterable', () => { + // date has no `get().loop` defined, and isn't bool — so it should + // still trigger the not-iterable error. + const probs = e.validate({ + kind: 'loop', + over: { kind: 'new', type: { name: 'date' }, value: '2026-04-30' }, + body: { kind: 'block', lines: [] }, + }); + expect(probs.list.some((p) => p.code === 'loop.not-iterable')).toBe(true); + }); + + test('body sees key as num{whole,min:0} and value as bool', () => { + // Validate that referencing key.add(...) in the body type-checks. + const probs = e.validate({ + kind: 'loop', + over: boolLit(true), + body: { + kind: 'get', + path: [ + { prop: 'key' }, { prop: 'add' }, + { args: { other: numLit(1) } }, + ], + }, + }); + expect(probs.list.some((p) => p.code === 'var.unknown')).toBe(false); + expect(probs.list.some((p) => p.code === 'prop.unknown')).toBe(false); + }); +}); diff --git a/packages/gin/src/__tests__/map.test.ts b/packages/gin/src/__tests__/map.test.ts index 8c6f0d85..7b7ae7ed 100644 --- a/packages/gin/src/__tests__/map.test.ts +++ b/packages/gin/src/__tests__/map.test.ts @@ -69,7 +69,7 @@ describe('MapType', () => { test('at method returns optional V', () => { const p = r.map(r.text(), r.num()).props(); - expect(p.at?.type.name).toBe('function'); + expect(p.at?.type.name).toBe('fn'); }); test('encode + parse roundtrip', () => { diff --git a/packages/gin/src/__tests__/natives-collections.test.ts b/packages/gin/src/__tests__/natives-collections.test.ts index 5972452d..087724e1 100644 --- a/packages/gin/src/__tests__/natives-collections.test.ts +++ b/packages/gin/src/__tests__/natives-collections.test.ts @@ -74,7 +74,7 @@ describe('list natives', () => { }); const gt2 = { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'bool' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'gt' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }] }, }; expect((await e.run(program(gt2, 'some'))).raw).toBe(true); @@ -123,7 +123,7 @@ describe('map natives', () => { describe('obj natives', () => { test('keys/values/entries/has', async () => { - const objType = { name: 'object', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; + const objType = { name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; const obj = { kind: 'new', type: objType, value: { name: 'Alice', age: 30 } }; const call = (prop: string, args: any = {}) => e.run({ kind: 'define', vars: [{ name: 's', value: obj }], @@ -135,7 +135,7 @@ describe('obj natives', () => { }); test('indexed access', async () => { - const objType = { name: 'object', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; + const objType = { name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; const obj = { kind: 'new', type: objType, value: { name: 'Alice', age: 30 } }; const result = await e.run({ kind: 'define', vars: [{ name: 's', value: obj }], diff --git a/packages/gin/src/__tests__/obj-compatible-widening.test.ts b/packages/gin/src/__tests__/obj-compatible-widening.test.ts new file mode 100644 index 00000000..9781032b --- /dev/null +++ b/packages/gin/src/__tests__/obj-compatible-widening.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry } from '../registry'; + +/** + * `ObjType.compatible(other)` — used by edit-compat tooling and other + * subset checks. Semantics: "every value of `other` is also a valid + * value of `this`". For obj types specifically: + * + * - Each field declared on `this` must appear on `other` with a + * type that satisfies `thisField.compatible(otherField)` — + * OR be optional, in which case `other` may simply omit it. + * - In `opts.exact`, the field sets must match exactly (no extras + * on `this` beyond what `other` declares). + * - `other` may have extra fields that `this` doesn't declare — + * those are ignored by `this`'s validator and don't affect + * compatibility. + * + * The "extra optional fields on `this`" rule is what makes the + * canonical edit-compat scenario work without a special API: + * `{x:num, y?:bool}.compatible({x:num})` is true because callers + * producing the simpler shape still produce values the wider shape + * accepts (the missing `y` defaults to undefined, which optional + * handles). + */ +describe('ObjType.compatible — widening / edit-compat scenarios', () => { + const r = createRegistry(); + + test('identical shapes compatible', () => { + const a = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + const b = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + expect(a.compatible(b)).toBe(true); + expect(b.compatible(a)).toBe(true); + }); + + test('this has extra OPTIONAL field — other may omit it', () => { + const wider = r.obj({ x: { type: r.num() }, flag: { type: r.optional(r.bool()) } }); + const narrow = r.obj({ x: { type: r.num() } }); + // Every narrow value (no flag) is valid for wider (flag undefined → optional accepts). + expect(wider.compatible(narrow)).toBe(true); + // Reverse: every wider value (with flag) is valid for narrow (extra ignored). + expect(narrow.compatible(wider)).toBe(true); + }); + + test('this has extra REQUIRED field — other must have it too', () => { + const wider = r.obj({ x: { type: r.num() }, flag: { type: r.bool() } }); + const narrow = r.obj({ x: { type: r.num() } }); + // narrow values lack `flag`; wider expects it required → not compatible. + expect(wider.compatible(narrow)).toBe(false); + // Other direction: every wider value still satisfies narrow (extras ignored). + expect(narrow.compatible(wider)).toBe(true); + }); + + test('canonical edit example — replacement direction', () => { + // old = {x:num, y:num}; new = {x:num, y:num|text, z?:bool} + // Every old value satisfies new ⇒ `new.compatible(old) === true`. + // Some new values DON'T satisfy old (y can be text) ⇒ `old.compatible(new) === false`. + const oldT = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + const newT = r.obj({ + x: { type: r.num() }, + y: { type: r.or([r.num(), r.text()]) }, + z: { type: r.optional(r.bool()) }, + }); + expect(newT.compatible(oldT)).toBe(true); + expect(oldT.compatible(newT)).toBe(false); + }); + + test('exact mode rejects extras on either side', () => { + const a = r.obj({ x: { type: r.num() }, y: { type: r.optional(r.bool()) } }); + const b = r.obj({ x: { type: r.num() } }); + // Without exact: a has optional y, b lacks it — accepted. + expect(a.compatible(b)).toBe(true); + // With exact: extras on `a` not in `b` are rejected. + expect(a.compatible(b, { exact: true })).toBe(false); + }); + + test('removing a required field is rejected for replacement', () => { + // old has y required; new omits y entirely. + const oldT = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + const newT = r.obj({ x: { type: r.num() } }); + // new.compatible(old)? new iterates new's only field (x), finds it on + // old with compatible type. Returns true — but this answers the wrong + // question for edit-compat (it just confirms new is a subset). + expect(newT.compatible(oldT)).toBe(true); + // old.compatible(new)? old iterates {x, y}. y not on new, y is REQUIRED + // on old → false. This is the rejection we want. + expect(oldT.compatible(newT)).toBe(false); + // The correct edit-compat question is "does the new contract preserve + // the old's guarantees?" which boils down to checking BOTH directions + // when shapes change: old.compatible(new) must be true (no fields lost) + // AND new.compatible(old) must be true (no required fields added). + // Edit tooling should call both; here y-loss flips one side false. + }); + + test('field type widening accepted in replacement direction', () => { + const oldT = r.obj({ y: { type: r.num() } }); + const newT = r.obj({ y: { type: r.or([r.num(), r.text()]) } }); + // new.compatible(old): or.compatible(num) is true (or accepts num). + expect(newT.compatible(oldT)).toBe(true); + // Reverse fails: num.compatible(or) is false. + expect(oldT.compatible(newT)).toBe(false); + }); + + test('field type narrowing rejected in replacement direction', () => { + const oldT = r.obj({ y: { type: r.or([r.num(), r.text()]) } }); + const newT = r.obj({ y: { type: r.num() } }); + // new.compatible(old): num.compatible(or) is false (num doesn't + // accept text). Narrowing breaks callers who supply text. + expect(newT.compatible(oldT)).toBe(false); + }); +}); diff --git a/packages/gin/src/__tests__/path-auto-call.test.ts b/packages/gin/src/__tests__/path-auto-call.test.ts new file mode 100644 index 00000000..93c487b9 --- /dev/null +++ b/packages/gin/src/__tests__/path-auto-call.test.ts @@ -0,0 +1,196 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine } from '../index'; + +/** + * Path walker auto-call: when a `{prop: 'method'}` step lands on a + * callable whose args type has no required fields (zero fields, or + * every field is `optional<...>`), the walker invokes the method + * with empty args instead of returning the bare function value. + * + * Coverage spans the three places the rule has to agree: + * - `evaluate` (runtime) + * - `typeOf` (static type inference) + * - `validateWalk` (static analysis used by `engine.validate`) + * + * Plus the negative cases — explicit `{args: {}}` still works, methods + * with required args are NOT auto-called, and standalone fn-typed + * scope variables stay as function values. + */ +describe('path auto-call (zero-required-arg methods)', () => { + const r = createRegistry(); + const e = new Engine(r); + + test('runtime: optional.has auto-invokes → bool (present)', async () => { + // Build the optional Value programmatically and pass it as + // an `extras` binding. Creating the present optional via gin's + // type.parse(42) is the simplest way to materialize a real + // OptionalType-typed Value at runtime. + const opt = r.optional(r.num()).parse(42); + const v = await e.run( + { kind: 'get', path: [{ prop: 'opt' }, { prop: 'has' }] }, + { opt }, + ); + expect(v.raw).toBe(true); + }); + + test('runtime: optional.has auto-invokes → bool (absent)', async () => { + // Same access pattern, but the optional has no value bound; auto- + // call still fires and returns false. + const opt = r.optional(r.num()).parse(undefined); + const v = await e.run( + { kind: 'get', path: [{ prop: 'opt' }, { prop: 'has' }] }, + { opt }, + ); + expect(v.raw).toBe(false); + }); + + test('runtime: explicit {args: {}} still works (back-compat)', async () => { + // Programs that already had the empty-args step keep behaving the + // same — the explicit-call branch fires before the auto-call + // branch. + const opt = r.optional(r.num()).parse(7); + const v = await e.run( + { kind: 'get', path: [{ prop: 'opt' }, { prop: 'has' }, { args: {} }] }, + { opt }, + ); + expect(v.raw).toBe(true); + }); + + test('runtime: text.upper auto-invokes → text', async () => { + // `text.upper` is `({}, text)` — zero args. Reading `s.upper` + // without an explicit call step should produce the upper-cased + // string. + const v = await e.run( + { kind: 'get', path: [{ prop: 's' }, { prop: 'upper' }] }, + { s: r.text().parse('hello') }, + ); + expect(v.raw).toBe('HELLO'); + }); + + test('runtime: method with required arg is NOT auto-called', async () => { + // num.add(other: num) has a required arg, so accessing + // `n.add` without {args: ...} should NOT auto-call. Using it + // explicitly with `{args: {other: 3}}` must still work. + const v = await e.run( + { + kind: 'get', + path: [ + { prop: 'n' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 3 } } }, + ], + }, + { n: r.num().parse(5) }, + ); + expect(v.raw).toBe(8); + }); + + test('runtime: standalone fn-typed scope var is NOT auto-called', async () => { + // Bare scope-var access (current === null in the walker) is the + // "give me the function value" path. Even if the fn has zero + // required args, the user has to invoke it explicitly with a + // CallStep — otherwise we'd lose the ability to pass functions + // around. We construct a no-arg lambda in scope via a `define + + // lambda` ExprDef, read it bare (gets the fn value), then call + // it with explicit empty args (gets the body's value). + const fnTypeDef = { name: 'fn' as const, call: { args: { name: 'obj' as const }, returns: { name: 'num' as const } } }; + const lambdaExpr = { + kind: 'lambda' as const, + type: fnTypeDef, + body: { kind: 'new' as const, type: { name: 'num' as const }, value: 99 }, + }; + + // Bare access — should yield the fn value (typeof === 'function'), + // NOT 99. If auto-call were firing on scope vars, this would + // return 99 and the assertion would fail. + const bare = await e.run({ + kind: 'define', + vars: [{ name: 'f', value: lambdaExpr }], + body: { kind: 'get', path: [{ prop: 'f' }] }, + }); + expect(typeof bare.raw).toBe('function'); + + // Explicit call — should yield 99. + const called = await e.run({ + kind: 'define', + vars: [{ name: 'f', value: lambdaExpr }], + body: { kind: 'get', path: [{ prop: 'f' }, { args: {} }] }, + }); + expect(called.raw).toBe(99); + }); + + test('typeOf: auto-callable prop reports the call return type', () => { + // `engine.validate` walks the program with `typeOf` to infer the + // value's type. For an auto-call site, that should be the call's + // `returns`, not the fn type itself. We exercise this through a + // `define` whose declared type is `bool`: if `opt.has` resolved + // to a fn, we'd get a `define.var.type-mismatch` error. + const probs = e.validate( + { + kind: 'define', + vars: [{ + name: 'present', + type: { name: 'bool' }, + value: { kind: 'get', path: [{ prop: 'opt' }, { prop: 'has' }] }, + }], + body: { kind: 'get', path: [{ prop: 'present' }] }, + }, + // Pre-bind `opt` in the type scope as `optional`. + new Map([['opt', r.optional(r.num())]]), + ); + expect(probs.list.some((p) => p.code === 'define.var.type-mismatch')).toBe(false); + }); + + test('validateWalk: auto-callable prop is OK as an if condition', () => { + // The exact case from the user's ginny.log: a conditional that + // tests `optional.has`. Pre-fix, this flagged + // `if.condition.type: got 'fn'`. With auto-call, the condition + // resolves to bool and the warning goes away. + const probs = e.validate( + { + kind: 'if', + ifs: [{ + condition: { kind: 'get', path: [{ prop: 'opt' }, { prop: 'has' }] }, + body: { kind: 'new', type: { name: 'num' }, value: 1 }, + }], + otherwise: { kind: 'new', type: { name: 'num' }, value: 2 }, + }, + new Map([['opt', r.optional(r.num())]]), + ); + expect(probs.list.some((p) => p.code === 'if.condition.type')).toBe(false); + }); + + test('validateWalk: required-arg method without {args:...} is still treated as fn', () => { + // num.add takes a required `other`. Reading `n.add` without + // calling it should give the fn value — using it directly as + // an if condition should warn about the bool mismatch. The + // improved message renders the fn's `toCode()` (which produces + // `(other: num): num` form), so we look for the return-type + // marker `): ` that's unique to fn rendering. + const probs = e.validate( + { + kind: 'if', + ifs: [{ + condition: { kind: 'get', path: [{ prop: 'n' }, { prop: 'add' }] }, + body: { kind: 'new', type: { name: 'num' }, value: 1 }, + }], + otherwise: { kind: 'new', type: { name: 'num' }, value: 2 }, + }, + new Map([['n', r.num()]]), + ); + const cond = probs.list.find((p) => p.code === 'if.condition.type'); + expect(cond).toBeDefined(); + expect(cond?.message).toMatch(/\): /); + }); + + test('runtime: method-chain auto-call — text.upper.lower', async () => { + // `text.upper` and `text.lower` both take zero args. Accessing + // `s.upper.lower` should auto-call each step in turn — first + // upper-casing then lower-casing — yielding the original string + // (lowercased). + const v = await e.run( + { kind: 'get', path: [{ prop: 's' }, { prop: 'upper' }, { prop: 'lower' }] }, + { s: r.text().parse('Hello') }, + ); + expect(v.raw).toBe('hello'); + }); +}); diff --git a/packages/gin/src/__tests__/person.test.ts b/packages/gin/src/__tests__/person.test.ts index a449d006..bf514365 100644 --- a/packages/gin/src/__tests__/person.test.ts +++ b/packages/gin/src/__tests__/person.test.ts @@ -33,7 +33,7 @@ describe('Person.fullName integration', () => { template: { kind: 'new', type: { name: 'text' }, value: '{first} {last}' }, params: { kind: 'new', - type: { name: 'object', props: { + type: { name: 'obj', props: { first: { type: { name: 'text' } }, last: { type: { name: 'text' } }, } }, @@ -106,7 +106,7 @@ describe('Person.fullName integration', () => { template: { kind: 'new', type: { name: 'text' }, value: '{first} {last}' }, params: { kind: 'new', - type: { name: 'object', props: { + type: { name: 'obj', props: { first: { type: { name: 'text' } }, last: { type: { name: 'text' } }, } }, @@ -130,7 +130,9 @@ describe('Person.fullName integration', () => { } as const; const code = e.toCode(program); - expect(code).toBe('p.fullName({})'); + // Empty args render as bare `()` — the implicit `{}` is dropped + // for readability. See path.ts CallStep.toCode. + expect(code).toBe('p.fullName()'); // typeOf on the method call should resolve to text via the Fn's returns. // (Requires a scope with p: Person; validate helps here.) diff --git a/packages/gin/src/__tests__/readme.test.ts b/packages/gin/src/__tests__/readme.test.ts index 0222356b..797d0c0e 100644 --- a/packages/gin/src/__tests__/readme.test.ts +++ b/packages/gin/src/__tests__/readme.test.ts @@ -67,8 +67,8 @@ describe('README examples', () => { fn: { kind: 'lambda', type: { - name: 'function', - call: { args: { name: 'object' }, returns: { name: 'bool' } }, + name: 'fn', + call: { args: { name: 'obj' }, returns: { name: 'bool' } }, }, body: { kind: 'get', diff --git a/packages/gin/src/__tests__/recurse.test.ts b/packages/gin/src/__tests__/recurse.test.ts index 9b257d90..42c626c4 100644 --- a/packages/gin/src/__tests__/recurse.test.ts +++ b/packages/gin/src/__tests__/recurse.test.ts @@ -19,9 +19,9 @@ describe('recurse in lambda body', () => { value: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, @@ -217,9 +217,9 @@ describe('recurse in CallDef.get', () => { test('JSON-declared callable recurses via `recurse`', async () => { const r = createRegistry(); const countdownFn = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, get: { kind: 'if', @@ -281,9 +281,9 @@ describe('recurse in CallDef.set (method)', () => { const r = createRegistry(); // x.drain({k}) = _ — walks k..0, pushing each to log via recurse. const drainFn = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { k: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'num' } } } }, returns: { name: 'num' }, set: { kind: 'block', @@ -367,9 +367,9 @@ describe('recurse in CallDef.set (direct call)', () => { test('direct-call setter recurses', async () => { const r = createRegistry(); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { k: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'num' } } } }, returns: { name: 'num' }, set: { kind: 'block', diff --git a/packages/gin/src/__tests__/recursive-types.test.ts b/packages/gin/src/__tests__/recursive-types.test.ts index dccb36a1..ec010dfc 100644 --- a/packages/gin/src/__tests__/recursive-types.test.ts +++ b/packages/gin/src/__tests__/recursive-types.test.ts @@ -4,7 +4,7 @@ import { createRegistry } from '../registry'; /** * Self-referential types (tree `Node` with `children: Node[]`) and mutual * cycles (`Task.creator: User` + `User.tasks: list`) are expressible - * via `r.ref(name)` — RefType resolves lazily through the registry, so + * via `r.alias(name)` — RefType resolves lazily through the registry, so * cross-references don't need the target to exist at construction time. * * These tests pin the behavior end-to-end: construction, rendering, @@ -14,11 +14,11 @@ import { createRegistry } from '../registry'; describe('recursive types', () => { test('self-reference: Node with optional children list', () => { const r = createRegistry(); - const Node = r.extend('object', { + const Node = r.extend('obj', { name: 'Node', props: { value: { type: r.num() }, - children: { type: r.optional(r.list(r.ref('Node'))) }, + children: { type: r.optional(r.list(r.alias('Node'))) }, }, }); r.register(Node); @@ -33,11 +33,11 @@ describe('recursive types', () => { test('self-reference: parse a nested value through the ref', () => { const r = createRegistry(); - const Node = r.extend('object', { + const Node = r.extend('obj', { name: 'Node', props: { value: { type: r.num() }, - children: { type: r.optional(r.list(r.ref('Node'))) }, + children: { type: r.optional(r.list(r.alias('Node'))) }, }, }); r.register(Node); @@ -58,39 +58,41 @@ describe('recursive types', () => { test('self-reference: JSON serializes the ref by NAME, not expanded', () => { const r = createRegistry(); - const Node = r.extend('object', { + const Node = r.extend('obj', { name: 'Node', props: { value: { type: r.num() }, - children: { type: r.optional(r.list(r.ref('Node'))) }, + children: { type: r.optional(r.list(r.alias('Node'))) }, }, }); r.register(Node); const json = JSON.stringify(Node.toJSON()); - // Exactly one ref mention inside (for the children's inner type); - // no recursive explosion of the props tree. - const refMatches = json.match(/"name":"ref"/g) ?? []; - expect(refMatches.length).toBe(1); - expect(json).toContain('"options":{"name":"Node"}'); + // The self-reference uses the bare-name shape `{"name":"Node"}` — + // emitted exactly twice in the props tree: once as the outer + // type's `name` (the Node type itself) and once as the inner ref + // inside `children`'s list. No recursive explosion (would be 100s + // if Node's props were re-inlined inside its own children). + const matches = json.match(/"name":"Node"/g) ?? []; + expect(matches.length).toBe(2); }); test('mutual cycle: Task ↔ User', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', props: { title: { type: r.text({ minLength: 1 }) }, - creator: { type: r.ref('User') }, + creator: { type: r.alias('User') }, }, }); r.register(Task); - const User = r.extend('object', { + const User = r.extend('obj', { name: 'User', props: { name: { type: r.text() }, - tasks: { type: r.list(r.ref('Task')) }, + tasks: { type: r.list(r.alias('Task')) }, }, }); r.register(User); @@ -106,19 +108,19 @@ describe('recursive types', () => { test('mutual cycle: round-trips through JSON into a fresh registry', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', props: { title: { type: r.text() }, - creator: { type: r.ref('User') }, + creator: { type: r.alias('User') }, }, }); r.register(Task); - const User = r.extend('object', { + const User = r.extend('obj', { name: 'User', props: { name: { type: r.text() }, - tasks: { type: r.list(r.ref('Task')) }, + tasks: { type: r.list(r.alias('Task')) }, }, }); r.register(User); @@ -136,13 +138,17 @@ describe('recursive types', () => { expect(User2.toCodeDefinition()).toContain('tasks: list'); }); - test('ref to an unregistered name resolves lazily — error surfaces on use', () => { + test('ref to an unregistered name resolves lazily — placeholder semantics', () => { const r = createRegistry(); - const ref = r.ref('DoesNotExist'); + const ref = r.alias('DoesNotExist'); // Construction + toJSON don't touch resolve(). expect(ref.toCode()).toBe('DoesNotExist'); - expect(ref.toJSON().name).toBe('ref'); - // But actually exercising the ref fails. - expect(() => ref.parse({})).toThrow(/not registered/); + // Bare-name JSON form — no `ref` wrapper. + expect(ref.toJSON().name).toBe('DoesNotExist'); + // Unresolved alias acts as a permissive placeholder (matches the + // unbound-generic behavior). Once `DoesNotExist` is registered, + // future calls would delegate to that target. + expect(ref.valid({})).toBe(true); + expect(ref.compatible(r.num())).toBe(true); }); }); diff --git a/packages/gin/src/__tests__/ref.test.ts b/packages/gin/src/__tests__/ref.test.ts index 0bd95609..ee384d5d 100644 --- a/packages/gin/src/__tests__/ref.test.ts +++ b/packages/gin/src/__tests__/ref.test.ts @@ -1,19 +1,24 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; -import { RefType } from '../types/ref'; +import { AliasType } from '../types/alias'; import { NumType } from '../types/num'; -describe('RefType', () => { +/** + * Reference-style aliases: `r.alias(name)` produces a lazy bare-name + * reference. Resolution walks `scope.lookup`, hitting the registered + * named type or built-in class. Replaces the former dedicated `RefType`. + */ +describe('AliasType (reference flavor)', () => { const r = createRegistry(); test('builder stores the name', () => { - const t = r.ref('num') as RefType; - expect(t).toBeInstanceOf(RefType); + const t = r.alias('num') as AliasType; + expect(t).toBeInstanceOf(AliasType); expect(t.options.name).toBe('num'); }); test('resolves via registry for built-in', () => { - const t = r.ref('num'); + const t = r.alias('num'); expect(t.valid(5)).toBe(true); expect(t.valid('x')).toBe(false); }); @@ -22,33 +27,42 @@ describe('RefType', () => { const reg = createRegistry(); const custom = reg.extend('num', { name: 'myNum', options: { min: 0 } }); reg.register(custom); - const ref = reg.ref('myNum'); + const ref = reg.alias('myNum'); expect(ref.valid(5)).toBe(true); expect(ref.valid(-1)).toBe(false); }); - test('unresolved ref throws on use', () => { - expect(() => r.ref('does-not-exist').valid(1)).toThrow(); + test('unresolved alias is permissive (placeholder semantics)', () => { + // Forward-ref / unresolved name acts as an unbound placeholder: + // permissive valid/compatible, no props. Once the name registers, + // the alias starts delegating. + const t = r.alias('does-not-exist'); + expect(t.valid(1)).toBe(true); }); test('flexible is true', () => { - expect(r.ref('num').flexible()).toBe(true); + expect(r.alias('num').flexible()).toBe(true); }); test('props delegate to resolved target', () => { - const t = r.ref('num'); + const t = r.alias('num'); const p = t.props(); expect(p.add).toBeDefined(); }); test('simplify returns the resolved target', () => { - expect(r.ref('num').simplify()).toBeInstanceOf(NumType); + expect(r.alias('num').simplify()).toBeInstanceOf(NumType); }); test('encode + parse roundtrip', () => { - const t = r.ref('num'); - const back = r.parse(t.toJSON()) as RefType; - expect(back).toBeInstanceOf(RefType); - expect(back.options.name).toBe('num'); + const t = r.alias('num'); + const json = t.toJSON(); + expect(json).toEqual({ name: 'num' }); + // Re-parsing the bare-name form returns the canonical class + // instance directly (since 'num' is a built-in class), not an + // AliasType wrapper. Structural equality is preserved. + const back = r.parse(json); + expect(back.name).toBe('num'); + expect(back.valid(5)).toBe(true); }); }); diff --git a/packages/gin/src/__tests__/registry-augment.test.ts b/packages/gin/src/__tests__/registry-augment.test.ts new file mode 100644 index 00000000..3a5e19fe --- /dev/null +++ b/packages/gin/src/__tests__/registry-augment.test.ts @@ -0,0 +1,341 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, Call, GetSet, Init, val, Value } from '../index'; + +/** + * `Registry.augment(name, { props?, get?, call?, init? })` lets a dev + * extend an existing built-in or registered type WITHOUT subclassing + * or wrapping it in an Extension. The added surface flows through + * `Type.props` / `Type.get` / `Type.call` / `Type.init`, so it shows + * up at runtime path-walks, in static type analysis, and in + * `toCodeDefinition` rendering. + */ +describe('Registry.augment', () => { + test('add a method to text — visible via path access', async () => { + const r = createRegistry(); + const e = new Engine(r); + r.augment('text', { + props: { shout: r.method({}, r.text(), 'text.shout') }, + }); + r.setNative('text.shout', (scope, reg) => { + const self = scope.get('this')!.raw as string; + return val(reg.text(), self.toUpperCase() + '!'); + }); + + const program = { + kind: 'get', + path: [{ prop: 's' }, { prop: 'shout' }, { args: {} }], + } as const; + const result = await e.run(program, { s: r.text().parse('hello') }); + expect(result.raw).toBe('HELLO!'); + }); + + test('augmented prop is visible in toCodeDefinition', () => { + const r = createRegistry(); + r.augment('text', { + props: { shout: r.method({}, r.text(), 'text.shout') }, + }); + const def = r.text().toCodeDefinition(); + expect(def).toMatch(/shout\(\): text/); + }); + + test('augmented props do NOT override intrinsic — `num.add` stays intact', () => { + const r = createRegistry(); + r.augment('num', { + // attempt to override num.add with a wrong-shape stub + props: { add: r.method({}, r.text(), 'fake.add') }, + }); + const num = r.num(); + const add = num.prop('add'); + expect(add?.type.call?.()?.returns?.name).toBe('num'); + }); + + test('add `get` to a type that has none — date becomes iterable', async () => { + const r = createRegistry(); + const e = new Engine(r); + // Loop yields three consecutive days starting from `this`. + // `yield` is path-shaped: takes a single `{key, value}` args Value. + r.setNative('date.dayLoop', async (scope, reg) => { + const self = scope.get('this')!.raw as Date; + const yieldFn = scope.get('yield')!.raw as (args: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + const dateType = reg.date(); + const argsType = reg.obj({ + key: { type: indexType }, value: { type: dateType }, + }); + const start = self.getTime(); + for (let i = 0; i < 3; i++) { + await yieldFn(new Value(argsType as any, { + key: val(indexType, i), + value: val(dateType, new Date(start + i * 86400_000)), + } as any)); + } + return val(reg.void(), undefined); + }); + r.augment('date', { + get: new GetSet({ + key: r.num({ whole: true, min: 0 }), + value: r.date(), + loop: { kind: 'native', id: 'date.dayLoop' }, + }), + }); + + // date now reports a `get`/`loop` surface. + const dateGet = r.date().get(); + expect(dateGet).toBeDefined(); + expect(dateGet?.loop).toEqual({ kind: 'native', id: 'date.dayLoop' }); + + // Run a loop that collects the iteration count via a counter. + const program = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [ + { name: 'count', value: { kind: 'new', type: { name: 'num', options: { whole: true, min: 0 } }, value: 0 } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'd' }] }, + body: { + kind: 'set', + path: [{ prop: 'count' }], + value: { + kind: 'get', + path: [ + { prop: 'count' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 1 } } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'count' }] }, + ], + }, + }, + ], + } as const; + const result = await e.run(program, { + d: r.date().parse(new Date('2026-01-01')), + }); + expect(result.raw).toBe(3); + }); + + test('add `call` to a type that has none — make timestamp callable', () => { + const r = createRegistry(); + r.augment('timestamp', { + call: new Call({ + args: r.obj({ offsetDays: { type: r.num() } }) as any, + returns: r.timestamp(), + }), + }); + const ts = r.timestamp(); + const call = ts.call(); + expect(call).toBeDefined(); + expect(call?.returns?.name).toBe('timestamp'); + }); + + test('augmented `init` — `new (args)` invokes the init expression', async () => { + // `text` doesn't have a native init. Augment it with one that + // formats `{name, count}` args into a custom string. + const r = createRegistry(); + const e = new Engine(r); + r.setNative('text.greet.init', (scope, reg) => { + const args = scope.get('args')!.raw as Record; + const name = args['name']!.raw as string; + const count = args['count']!.raw as number; + return val(reg.text(), `Hello ${name} x${count}`); + }); + r.augment('text', { + init: new Init({ + args: r.obj({ + name: { type: r.text() }, + count: { type: r.num({ whole: true, min: 1 }) }, + }) as any, + run: { kind: 'native', id: 'text.greet.init' }, + }), + }); + + // `new text { name: "World", count: 3 }` should call init.run with + // `args` bound and return its result as a text Value. + const program = { + kind: 'new', + type: { name: 'text' }, + value: { name: 'World', count: 3 }, + } as const; + const result = await e.run(program); + expect(result.raw).toBe('Hello World x3'); + }); + + test('augmentation is also picked up by Extensions over the augmented type', () => { + const r = createRegistry(); + r.augment('num', { + props: { stamp: r.method({}, r.text(), 'num.stamp') }, + }); + const positiveInt = r.extend(r.num({ whole: true, min: 1 }), { + name: 'PositiveInt', + }); + r.register(positiveInt); + expect(positiveInt.prop('stamp')).toBeDefined(); + }); + + test('custom loop Expr (non-native) — augmented type drives iteration via path-callable yield', async () => { + // Augment `num` with a SECOND loop shape via Extension — actually, + // simpler: register a fresh `Pair` type whose `loop` is a plain + // `block` that calls `yield(...)` twice via path. The path-callable + // yield (an obj `{key, value}` arg) is what makes a non-native + // loop ExprDef expressible. This is THE thing custom loops need: + // a path-shaped yield Value sitting in scope. + const r = createRegistry(); + const e = new Engine(r); + + // A custom loop ExprDef — a `block` of two `get` paths that each + // call `yield({ key: , value: })`. No natives involved. + const customLoop = { + kind: 'block', + lines: [ + { + kind: 'get', + path: [ + { prop: 'yield' }, + { + args: { + key: { kind: 'new', type: { name: 'num' }, value: 0 }, + value: { kind: 'new', type: { name: 'text' }, value: 'first' }, + }, + }, + ], + }, + { + kind: 'get', + path: [ + { prop: 'yield' }, + { + args: { + key: { kind: 'new', type: { name: 'num' }, value: 1 }, + value: { kind: 'new', type: { name: 'text' }, value: 'second' }, + }, + }, + ], + }, + ], + }; + + // Augment `text` with a loop that yields these two pairs whenever + // any text Value is iterated. (Replacing text's intrinsic + // `text.chars` is fine here because augmentation only fills gaps — + // text already has `get`, so this augmentation's `get` is dead; + // pick a type that has NONE instead.) + r.augment('null', { + get: new GetSet({ + key: r.num({ whole: true, min: 0 }), + value: r.text(), + loop: customLoop as any, + }), + }); + + // Run a loop over null. The custom loop should yield two pairs. + const program = { + kind: 'define', + vars: [ + { name: 'collected', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'null' } }, + body: { + kind: 'get', + path: [ + { prop: 'collected' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'collected' }] }, + ], + }, + } as const; + const v = await e.run(program); + const raw = (v.raw as Value[]).map((x) => x.raw); + expect(raw).toEqual(['first', 'second']); + }); + + test('augmented `init` shapes the `new` value schema', () => { + // The `value` slot of a `new T(args)` expression should match + // `init.args` whenever T has init defined — augmented or + // intrinsic. Verified by inspecting the Zod shape of the + // type's `toNewSchema()`. + const r = createRegistry(); + r.augment('text', { + init: new Init({ + args: r.obj({ + name: { type: r.text() }, + count: { type: r.num({ whole: true, min: 1 }) }, + }) as any, + run: { kind: 'native', id: 'text.greet.init' }, + }), + }); + + // Build a synthetic SchemaOptions just rich enough for toNewSchema. + const opts = { + Type: r.any() as any, + Expr: r.any() as any, + types: [], + exprs: [], + registry: r, + } as any; + + const schema = r.text().toNewSchema(opts); + // Should accept the init.args shape (and reject mismatched). + expect(schema.safeParse({ name: 'World', count: 3 }).success).toBe(true); + expect(schema.safeParse('plain string').success).toBe(false); + expect(schema.safeParse({ name: 'World' }).success).toBe(false); // count missing + }); + + test('intrinsic init also flows through (duration, color)', async () => { + // `duration.init.args` is `{days?, hours?, minutes?, seconds?, ms?}`. + // After the base `Type.toNewSchema` change, both static AND instance + // schemas should reflect this — not the legacy bare-number form. + const r = createRegistry(); + const opts = { + Type: r.any() as any, Expr: r.any() as any, + types: [], exprs: [], registry: r, + } as any; + const dSchema = r.duration().toNewSchema(opts); + expect(dSchema.safeParse({ days: 1, hours: 2 }).success).toBe(true); + expect(dSchema.safeParse(1234).success).toBe(false); + + // Color too — init.args is {r, g, b, a?}. + const cSchema = r.color().toNewSchema(opts); + expect(cSchema.safeParse({ r: 255, g: 0, b: 0 }).success).toBe(true); + expect(cSchema.safeParse(0xff0000ff).success).toBe(false); + }); + + test('repeated augment calls MERGE props, get/call/init are first-wins', () => { + const r = createRegistry(); + r.augment('text', { props: { a: r.method({}, r.text(), 'a.id') } }); + r.augment('text', { props: { b: r.method({}, r.text(), 'b.id') } }); + const props = r.text().props(); + expect(props['a']).toBeDefined(); + expect(props['b']).toBeDefined(); + + // First `init` wins; second is silently dropped. + const init1 = new Init({ + args: r.obj({}) as any, + run: { kind: 'native', id: 'init.first' }, + }); + const init2 = new Init({ + args: r.obj({}) as any, + run: { kind: 'native', id: 'init.second' }, + }); + r.augment('text', { init: init1 }); + r.augment('text', { init: init2 }); + const eff = r.text().init(); + expect(eff?.run).toEqual({ kind: 'native', id: 'init.first' }); + }); +}); diff --git a/packages/gin/src/__tests__/registry.test.ts b/packages/gin/src/__tests__/registry.test.ts index 8ceb6c05..d73ff114 100644 --- a/packages/gin/src/__tests__/registry.test.ts +++ b/packages/gin/src/__tests__/registry.test.ts @@ -24,14 +24,20 @@ describe('Registry', () => { expect(t).toBeInstanceOf(Extension); }); - test('parse throws for unknown name', () => { + test('parse of bare unknown name returns a lazy alias placeholder', () => { + // Unknown bare names route through AliasType so forward refs and + // self-referential types parse without an existence check. The + // alias resolves via scope.lookup at use time; unresolved aliases + // behave permissively (compatible / valid both pass) until the + // target gets registered. const r = createRegistry(); - expect(() => r.parse({ name: 'unknown-type' })).toThrow(); + const t = r.parse({ name: 'unknown_type' }); + expect(t.name).toBe('alias'); }); test('parse throws for extends of unknown base', () => { const r = createRegistry(); - expect(() => r.parse({ name: 'x', extends: 'does-not-exist' })).toThrow(); + expect(() => r.parse({ name: 'x', extends: 'does_not_exist' })).toThrow(); }); test('register + lookup roundtrip', () => { @@ -65,7 +71,7 @@ describe('Registry', () => { test('method helper builds fn-typed prop', () => { const r = createRegistry(); const p = r.method({ other: r.num() }, r.bool(), 'x.method'); - expect(p.type.name).toBe('function'); + expect(p.type.name).toBe('fn'); expect((p.get as any).id).toBe('x.method'); }); @@ -78,8 +84,9 @@ describe('Registry', () => { expect(t.name).toBe('list'); }); - test('empty Registry (no builtins) rejects parse', () => { + test('empty Registry (no builtins) parses bare name as a lazy alias', () => { const r = new Registry(); - expect(() => r.parse({ name: 'num' })).toThrow(); + const t = r.parse({ name: 'num' }); + expect(t.name).toBe('alias'); }); }); diff --git a/packages/gin/src/__tests__/scopes-typedef.test.ts b/packages/gin/src/__tests__/scopes-typedef.test.ts index 4972db6b..3b126247 100644 --- a/packages/gin/src/__tests__/scopes-typedef.test.ts +++ b/packages/gin/src/__tests__/scopes-typedef.test.ts @@ -439,9 +439,9 @@ describe('CallDef.get body', () => { const r = createRegistry(); const e = new Engine(r); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { x: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' }, get: { kind: 'get', @@ -471,7 +471,7 @@ describe('PropDef.default', () => { const v = await e.run({ kind: 'new', type: { - name: 'object', + name: 'obj', props: { name: { type: { name: 'text' } }, greeting: { @@ -491,7 +491,7 @@ describe('PropDef.default', () => { const v = await e.run({ kind: 'new', type: { - name: 'object', + name: 'obj', props: { mode: { type: { name: 'text' }, @@ -515,9 +515,9 @@ describe('PathCall.catch scope', () => { value: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { - args: { name: 'object' }, + args: { name: 'obj' }, returns: { name: 'text' }, throws: { name: 'text' }, }, diff --git a/packages/gin/src/__tests__/super-override.test.ts b/packages/gin/src/__tests__/super-override.test.ts index db28af0b..4d3e2db0 100644 --- a/packages/gin/src/__tests__/super-override.test.ts +++ b/packages/gin/src/__tests__/super-override.test.ts @@ -343,9 +343,9 @@ describe('super in CallDef.set (method call.set) override', () => { const r = createRegistry(); // Base method has call.set that pushes (args.k, value) onto baseLog. const baseFn = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'block', @@ -381,9 +381,9 @@ describe('super in CallDef.set (method call.set) override', () => { // Override: push into overrideLog, then super({args: args, value: value + 1000}). const overrideFn = r.parse({ - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'block', diff --git a/packages/gin/src/__tests__/toCode.test.ts b/packages/gin/src/__tests__/toCode.test.ts index 39f4d0f1..8e4302be 100644 --- a/packages/gin/src/__tests__/toCode.test.ts +++ b/packages/gin/src/__tests__/toCode.test.ts @@ -105,14 +105,14 @@ describe('Type.toCode — functions and references', () => { expect(fn.toCode()).toBe('(): void'); }); test('fn with generics', () => { - const fn = r.fn(r.obj({ x: { type: r.generic('T') } }), r.generic('T'), undefined, { T: r.any() }); + const fn = r.fn(r.obj({ x: { type: r.alias('T') } }), r.alias('T'), undefined, { T: r.any() }); expect(fn.toCode()).toBe('(x: T): T'); }); test('ref → bare name', () => { - expect(r.ref('User').toCode()).toBe('User'); + expect(r.alias('User').toCode()).toBe('User'); }); test('generic → bare name', () => { - expect(r.generic('T').toCode()).toBe('T'); + expect(r.alias('T').toCode()).toBe('T'); }); test('iface renders struct-style', () => { const t = r.iface({ @@ -193,7 +193,7 @@ describe('Engine.toCode — expressions', () => { vars: [{ name: 'x', value: { kind: 'new', type: { name: 'num' }, value: 10 } }], body: { kind: 'get', path: [{ prop: 'x' }] }, }); - expect(code).toContain('const x = 10'); + expect(code).toContain('let x = 10'); expect(code).toContain('x;'); }); @@ -203,7 +203,7 @@ describe('Engine.toCode — expressions', () => { vars: [{ name: 'x', value: { kind: 'new', type: { name: 'num' }, value: 10 } }], body: { kind: 'get', path: [{ prop: 'x' }] }, }, { expectsValue: true }); - expect(code).toContain('const x'); + expect(code).toContain('let x'); expect(code).toContain('return x'); }); @@ -310,13 +310,13 @@ describe('Engine.toCode — expressions', () => { condition: { kind: 'new', type: { name: 'bool' }, value: true }, body: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'flow', action: 'return', value: { kind: 'new', type: { name: 'num' }, value: 7 } }, }, }], else: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }, { expectsValue: true }); @@ -362,8 +362,8 @@ describe('Engine.toCode — expressions', () => { const code = e.toCode({ kind: 'lambda', type: { - name: 'function', - call: { args: { name: 'object', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' } }, + name: 'fn', + call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' } }, }, body: { kind: 'get', @@ -373,7 +373,7 @@ describe('Engine.toCode — expressions', () => { ], }, }); - expect(code).toBe('(args: obj{n: num}) => args.n.mul({ other: 2 })'); + expect(code).toBe('(n: num): num => args.n.mul({ other: 2 })'); }); test('template with static params inlines interpolations', () => { @@ -382,7 +382,7 @@ describe('Engine.toCode — expressions', () => { template: { kind: 'new', type: { name: 'text' }, value: 'Hello {name}!' }, params: { kind: 'new', - type: { name: 'object', props: { name: { type: { name: 'text' } } } }, + type: { name: 'obj', props: { name: { type: { name: 'text' } } } }, value: { name: 'Alice' }, }, }); diff --git a/packages/gin/src/__tests__/toSchema.test.ts b/packages/gin/src/__tests__/toSchema.test.ts index abd8bd32..0d513d50 100644 --- a/packages/gin/src/__tests__/toSchema.test.ts +++ b/packages/gin/src/__tests__/toSchema.test.ts @@ -29,7 +29,7 @@ describe('toSchema / buildSchemas', () => { test('obj with nested props parses', () => { expect(() => Type.parse({ - name: 'object', + name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } }, @@ -67,9 +67,9 @@ describe('toSchema / buildSchemas', () => { expect(() => Expr.parse({ kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, diff --git a/packages/gin/src/__tests__/typ-type.test.ts b/packages/gin/src/__tests__/typ-type.test.ts index e48ef4d3..71ad90c2 100644 --- a/packages/gin/src/__tests__/typ-type.test.ts +++ b/packages/gin/src/__tests__/typ-type.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; import { buildSchemas } from '../schemas'; import { TypType } from '../types/typ'; +import { LocalScope } from '../type-scope'; /** * TypType — a gin type whose runtime values ARE TypeDefs. Its generic T @@ -79,12 +80,15 @@ describe('TypType', () => { expect(r.typ(r.num()).compatible(r.num())).toBe(false); }); - test('generic binding: typ.bind({R: num}) → typ', () => { + test('generic resolution: typ with extra-scope R=num behaves as typ', () => { const r = createRegistry(); - const t = r.typ(r.generic('R')); - const bound = t.bind({ R: r.num() }) as TypType; - expect(bound).toBeInstanceOf(TypType); - expect(bound.constraint.name).toBe('num'); + const t = r.typ(r.alias('R')); + const local = new LocalScope(r, { R: r.num() }); + // typ.parse({name:'num'}, local) — R resolves to num via the + // extra scope, so num is a satisfying TypeDef. + expect(t.parse({ name: 'num' }, local).raw.name).toBe('num'); + // typ.parse({name:'text'}, local) — text is not num-compatible. + expect(() => t.parse({ name: 'text' }, local)).toThrow(); }); test('typ parse accepts any TypeDef JSON', () => { @@ -140,7 +144,7 @@ describe('TypType', () => { describe('TypType + generics', () => { test('unbound typ acts as typ — parse accepts any TypeDef', () => { const r = createRegistry(); - const t = r.typ(r.generic('R')); + const t = r.typ(r.alias('R')); expect(t.parse({ name: 'num' }).raw.name).toBe('num'); expect(t.parse({ name: 'text' }).raw.name).toBe('text'); expect(t.parse({ name: 'list', generic: { V: { name: 'num' } } }).raw.name).toBe('list'); @@ -148,55 +152,52 @@ describe('TypType + generics', () => { expect(() => t.parse(42)).toThrow(); }); - test('typ.bind({R: num}) narrows to typ', () => { + test('typ with extra-scope R=num accepts num and rejects text', () => { const r = createRegistry(); - const unbound = r.typ(r.generic('R')); - const bound = unbound.bind({ R: r.num() }) as TypType; - expect(bound).toBeInstanceOf(TypType); - expect(bound.constraint.name).toBe('num'); - expect(bound.parse({ name: 'num' }).raw.name).toBe('num'); - expect(() => bound.parse({ name: 'text' })).toThrow(); + const unbound = r.typ(r.alias('R')); + const local = new LocalScope(r, { R: r.num() }); + expect(unbound.parse({ name: 'num' }, local).raw.name).toBe('num'); + expect(() => unbound.parse({ name: 'text' }, local)).toThrow(); }); - test('fn<(args{output: typ}), R>.bind({R: num}) narrows output AND return', () => { + test('fn<(args{output: typ}), R> with R=num scope: returns resolves to num, output to optional>', () => { const r = createRegistry(); const fn = r.fn( - r.obj({ output: { type: r.optional(r.typ(r.generic('R'))) } }), - r.generic('R'), + r.obj({ output: { type: r.optional(r.typ(r.alias('R'))) } }), + r.alias('R'), undefined, { R: r.any() }, ); - const bound = fn.bind({ R: r.num() }); - const call = bound.call(); + const local = new LocalScope(r, { R: r.num() }); + const call = fn.call(local); expect(call).toBeTruthy(); - // Return type narrowed to num. - expect(call!.returns?.name).toBe('num'); - // output arg narrowed to optional>. + // Return type's resolved form is num via simplify(local). + expect(call!.returns?.simplify(local).name).toBe('num'); + // output arg is optional> — outer Optional doesn't change. const argsType = call!.args; - const outputProp = argsType.prop('output'); + const outputProp = argsType.prop('output', local); expect(outputProp).toBeTruthy(); expect(outputProp!.type.name).toBe('optional'); }); test('typ JSON round-trips preserving the generic placeholder', () => { const r = createRegistry(); - const t = r.typ(r.generic('R')); + const t = r.typ(r.alias('R')); const json = t.toJSON(); expect(json.name).toBe('typ'); - expect(json.generic?.T).toEqual({ name: 'generic', options: { name: 'R' } }); + // Bare-name form — AliasType.toJSON emits `{name: 'R'}`. + expect(json.generic?.T).toEqual({ name: 'R' }); const back = r.parse(json) as TypType; expect(back).toBeInstanceOf(TypType); - expect(back.constraint.name).toBe('generic'); + expect(back.constraint.name).toBe('alias'); }); - test('typ>.bind({R: num}) narrows list item', () => { + test('typ> with extra-scope R=num: list ok, list rejected', () => { const r = createRegistry(); - const t = r.typ(r.list(r.generic('R'))); - const bound = t.bind({ R: r.num() }) as TypType; - expect(bound.constraint.name).toBe('list'); - // Accepts list TypeDef; rejects list. - expect(bound.parse({ name: 'list', generic: { V: { name: 'num' } } }).raw.name).toBe('list'); - expect(() => bound.parse({ name: 'list', generic: { V: { name: 'text' } } })).toThrow(); + const t = r.typ(r.list(r.alias('R'))); + const local = new LocalScope(r, { R: r.num() }); + expect(t.parse({ name: 'list', generic: { V: { name: 'num' } } }, local).raw.name).toBe('list'); + expect(() => t.parse({ name: 'list', generic: { V: { name: 'text' } } }, local)).toThrow(); }); }); diff --git a/packages/gin/src/__tests__/type-jsoncode-demo.test.ts b/packages/gin/src/__tests__/type-jsoncode-demo.test.ts new file mode 100644 index 00000000..9e2e03a7 --- /dev/null +++ b/packages/gin/src/__tests__/type-jsoncode-demo.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, formatProblems } from '../index'; + +/** + * Visual demo — a sprawling type def with embedded Expr bodies in + * multiple slots, several deliberately broken. Renders the JSON-form + * of the type with `Type.toJSONCode` and runs `formatProblems` + * against `Type.validate(engine)` so the reader can eyeball the + * compiler-style `^^^` underlines landing inside the type def. + * + * Run with: `npx vitest run type-jsoncode-demo --reporter=verbose` + */ +describe('Type.toJSONCode + Type.validate — visual demo', () => { + test('Extension with embedded Expr bodies in props/get/call/init/constraint', () => { + const r = createRegistry(); + const e = new Engine(r); + + // A custom `Account` type — an obj with two fields, plus a method, + // plus a getter, plus a constraint, plus an init. + // Several embedded Exprs are deliberately broken so we can see the + // pointer output land precisely. + const Account = r.extend('obj', { + name: 'Account', + docs: 'a sample account type with broken bodies', + props: { + // Normal field — no embedded Expr. + balance: { type: r.num({ min: 0 }) }, + // Method that should return num but body returns text. + wrongType: { + type: r.fn(r.obj({}), r.num()), + get: { kind: 'new', type: { name: 'text' }, value: 'oops' }, + }, + // Method whose body references an unbound name. + undefinedRef: { + type: r.fn(r.obj({ x: { type: r.num() } }), r.num()), + get: { kind: 'get', path: [{ prop: 'unknownVar' }] }, + }, + // Method body that's actually fine — to show clean parts mixed + // in with the broken ones. + ok: { + type: r.fn(r.obj({}), r.num()), + get: { kind: 'new', type: { name: 'num' }, value: 42 }, + }, + }, + // Constraint should return bool but returns num. + constraint: r.parseExpr({ kind: 'new', type: { name: 'num' }, value: 1 }), + init: { + args: r.obj({ start: { type: r.num() } }), + // init.run references an unbound var. + run: { kind: 'get', path: [{ prop: 'thisDoesNotExist' }] }, + }, + }); + r.register(Account); + + // Render the type's JSON form with spans, and validate. + const codeObj = Account.toJSONCode([], 2, 0); + const probs = Account.validate(e); + + console.log('\n' + '═'.repeat(80)); + console.log(' Account — Type.toJSONCode() output'); + console.log('═'.repeat(80)); + console.log(codeObj.toString()); + console.log('\n' + '═'.repeat(80)); + console.log(` Account — ${probs.list.length} problems from Type.validate()`); + console.log('═'.repeat(80)); + for (const p of probs.list) { + console.log(` [${p.severity}] ${p.code}: ${p.message} @ ${p.path.join('.')}`); + } + console.log('\n' + '═'.repeat(80)); + console.log(' formatProblems(typeJsonCode, problems) — sectioned ^^^ output'); + console.log('═'.repeat(80)); + console.log(formatProblems(codeObj, probs, { color: false })); + + // Sanity: each broken slot produced a problem. + expect(probs.list.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/packages/gin/src/__tests__/type-jsoncode.test.ts b/packages/gin/src/__tests__/type-jsoncode.test.ts new file mode 100644 index 00000000..cb084f7c --- /dev/null +++ b/packages/gin/src/__tests__/type-jsoncode.test.ts @@ -0,0 +1,161 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, formatProblems } from '../index'; + +/** + * `Type.toJSONCode` walks the standard TypeDef structure and emits + * fine-grained spans on every nested slot — props/get/call/init, + * each Prop's get/set/default, embedded ExprDefs, etc. Combined with + * `Type.validate(engine)` (which surfaces problems with paths into + * the same structure), `formatProblem(typeJsonCode, problem)` can + * underline precisely the offending range INSIDE a type definition, + * the way it does inside Expr trees. + * + * The tests below exercise the path resolution end-to-end: build an + * augmented type with a deliberately broken Expr body, validate it, + * then check that the formatted output's underline lands on the + * expected piece of the rendered JSON. + */ +describe('Type.toJSONCode — fine spans inside type defs', () => { + test('Extension toJSONCode contains all structural keys with spans', () => { + const r = createRegistry(); + const Point = r.extend('obj', { + name: 'Point', + props: { + x: { type: r.num() }, + y: { type: r.num() }, + }, + }); + r.register(Point); + + const codeObj = Point.toJSONCode([], 2, 0); + const text = codeObj.toString(); + // Structural keys are rendered. + expect(text).toContain('"name": "Point"'); + expect(text).toContain('"props"'); + expect(text).toContain('"x"'); + expect(text).toContain('"y"'); + + // Spans are emitted at every level (not just one coarse top-level). + // Each Prop's `type` slot has its own span. + const xTypeSpan = codeObj.spanFor(['props', 'x', 'type']); + expect(xTypeSpan).toBeDefined(); + const yTypeSpan = codeObj.spanFor(['props', 'y', 'type']); + expect(yTypeSpan).toBeDefined(); + expect(xTypeSpan!.start).not.toBe(yTypeSpan!.start); + }); + + test('span for an embedded Prop.get path resolves precisely', () => { + const r = createRegistry(); + const e = new Engine(r); + const Sample = r.extend('obj', { + name: 'Sample', + props: { + // Method body is an Expr — referencing an unbound name. + broken: { + type: r.fn(r.obj({}), r.text()), + get: { kind: 'get', path: [{ prop: 'unboundName' }] }, + }, + }, + }); + r.register(Sample); + + const codeObj = Sample.toJSONCode([], 2, 0); + // The Expr's path is `['props', 'broken', 'get', 'path', 0]` for + // the unbound get — the validator emits exactly that path. + const inner = codeObj.spanFor(['props', 'broken', 'get', 'path', 0]); + expect(inner).toBeDefined(); + // Confirm validate produces a problem at that path so the two + // sides line up. + const probs = Sample.validate(e); + const varUnknown = probs.list.find((p) => p.code === 'var.unknown'); + expect(varUnknown).toBeDefined(); + // The problem path should be a prefix-match for the span we found. + // (`spanFor` uses longest-prefix matching, so the span's path is + // <= the problem's path in length.) + expect(varUnknown!.path).toEqual( + expect.arrayContaining(['props', 'broken', 'get']), + ); + }); + + test('formatProblems(typeJsonCode, problems) produces sectioned ^^^ output', () => { + const r = createRegistry(); + const e = new Engine(r); + const Broken = r.extend('obj', { + name: 'Broken', + props: { + rotateX: { + // method declares returns: num, body returns text — mismatch. + type: r.fn(r.obj({}), r.num()), + get: { kind: 'new', type: { name: 'text' }, value: 'oops' }, + }, + bad: { + // unbound name in method body. + type: r.fn(r.obj({}), r.text()), + get: { kind: 'get', path: [{ prop: 'whoIsThis' }] }, + }, + }, + }); + r.register(Broken); + + const probs = Broken.validate(e); + expect(probs.list.length).toBeGreaterThan(0); + + const codeObj = Broken.toJSONCode([], 2, 0); + const formatted = formatProblems(codeObj, probs, { color: false }); + // Each problem renders against its own line range with line numbers, + // a `^^^` underline, and the message — same shape as Expr-side. + expect(formatted).toMatch(/── lines \d+-\d+ ─/); + expect(formatted).toContain('^'); + // At least one error message references the bad slot's content. + expect(formatted).toMatch(/whoIsThis|unknown variable|return-type/); + }); + + test('Init.run inside a type def gets its own span path', () => { + const r = createRegistry(); + const e = new Engine(r); + // An Extension whose `init.run` references an unbound name. + const T = r.extend('obj', { + name: 'T', + props: { v: { type: r.num() } }, + init: { + args: r.obj({ start: { type: r.num() } }), + run: { kind: 'get', path: [{ prop: 'noSuchVar' }] }, + }, + }); + r.register(T); + + const codeObj = T.toJSONCode([], 2, 0); + const initRunSpan = codeObj.spanFor(['init', 'run']); + expect(initRunSpan).toBeDefined(); + + const probs = T.validate(e); + const violation = probs.list.find((p) => + p.code === 'var.unknown' && p.path[0] === 'init' && p.path[1] === 'run', + ); + expect(violation).toBeDefined(); + + const formatted = formatProblems(codeObj, probs, { color: false }); + expect(formatted).toContain('noSuchVar'); + }); + + test('roundtrip: toJSONCode().toString() parses back to toJSON()', () => { + const r = createRegistry(); + const Round = r.extend('obj', { + name: 'Round', + docs: 'a round trip test type', + props: { + x: { type: r.num({ min: 0, max: 100 }) }, + helper: { + type: r.fn(r.obj({ n: { type: r.num() } }), r.num()), + get: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] }, + }, + }, + }); + r.register(Round); + + const text = Round.toJSONCode([], 2, 0).toString(); + const parsed = JSON.parse(text); + // Equivalent to the canonical toJSON() output. + expect(parsed).toEqual(JSON.parse(JSON.stringify(Round.toJSON()))); + }); +}); diff --git a/packages/gin/src/__tests__/type-validate-surface.test.ts b/packages/gin/src/__tests__/type-validate-surface.test.ts new file mode 100644 index 00000000..8176d265 --- /dev/null +++ b/packages/gin/src/__tests__/type-validate-surface.test.ts @@ -0,0 +1,148 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine } from '../index'; + +/** + * `Type.validate(engine)` walks the type's surface (props / get / call / + * init) and validates every embedded ExprDef. Embedded bodies are + * parsed and run through the same `walkValidate` machinery a top-level + * program goes through, with the runtime scope (`this`, `args`, + * `recurse`, `super`, `key`, `value`) pre-bound. + * + * Programs validated via `engine.validate(programExpr)` do NOT auto- + * recurse into types — that's by design (a program shouldn't pay to + * re-validate the registry every time it touches `num`). Instead, + * `registry.validate(engine)` aggregates `Type.validate` across every + * named type + augmented built-in. + */ +describe('Type.validate — embedded Expr surface walk', () => { + test('plain built-in type: native-only props validate clean', () => { + const r = createRegistry(); + const e = new Engine(r); + const probs = r.num().validate(e); + // num's intrinsic methods are all `{kind:'native', id:'num.X'}`; + // each NativeExpr validates trivially as long as its impl is + // registered (which createRegistry does). + expect(probs.list.length).toBe(0); + }); + + test('augmented type with a well-formed Expr body validates clean', () => { + const r = createRegistry(); + const e = new Engine(r); + // Add `text.echo` returning `this` — a single-segment get path on + // the bound `this` Value. Body is real gin code, not a native id. + r.augment('text', { + props: { + echo: { type: r.fn(r.obj({}), r.text()), get: { kind: 'get', path: [{ prop: 'this' }] } }, + }, + }); + const probs = r.text().validate(e); + expect(probs.list).toEqual([]); + }); + + test('embedded body with var.unknown is caught', () => { + const r = createRegistry(); + const e = new Engine(r); + r.augment('text', { + props: { + // `unboundName` is not in scope — the slot only binds `this`/`args`/`recurse`. + broken: { type: r.fn(r.obj({}), r.text()), get: { kind: 'get', path: [{ prop: 'unboundName' }] } }, + }, + }); + const probs = r.text().validate(e); + expect(probs.list.some((p) => p.code === 'var.unknown')).toBe(true); + }); + + test('embedded body with wrong return type surfaces type.surface.return-type', () => { + const r = createRegistry(); + const e = new Engine(r); + // Method declares returns: num, but body produces text. + r.augment('text', { + props: { + wrongReturn: { + type: r.fn(r.obj({}), r.num()), + // `this` is text — returning it gives type 'text', not 'num'. + get: { kind: 'get', path: [{ prop: 'this' }] }, + }, + }, + }); + const probs = r.text().validate(e); + expect(probs.list.some((p) => p.code === 'type.surface.return-type')).toBe(true); + }); + + test('Extension method body is validated like an augmentation', () => { + const r = createRegistry(); + const e = new Engine(r); + const Point = r.extend('obj', { + name: 'Point', + props: { x: { type: r.num() }, y: { type: r.num() } }, + }); + r.register(Point); + // Augment the registered Extension with a method whose body refs + // an unbound name. + r.augment('Point', { + props: { + broken: { + type: r.fn(r.obj({}), r.num()), + get: { kind: 'get', path: [{ prop: 'doesNotExist' }] }, + }, + }, + }); + const probs = Point.validate(e); + expect(probs.list.some((p) => p.code === 'var.unknown')).toBe(true); + }); + + test('program validation does NOT auto-recurse into types', () => { + // Even though `text` is now augmented with a broken method, a + // program that doesn't call that method validates clean. Programs + // are scoped to their own tree. + const r = createRegistry(); + const e = new Engine(r); + r.augment('text', { + props: { + broken: { + type: r.fn(r.obj({}), r.text()), + get: { kind: 'get', path: [{ prop: 'unbound' }] }, + }, + }, + }); + const program = { kind: 'new' as const, type: { name: 'text' as const }, value: 'hello' }; + const probs = e.validate(program); + expect(probs.list.length).toBe(0); + }); + + test('registry.validate aggregates Type.validate across named + augmented', () => { + const r = createRegistry(); + const e = new Engine(r); + // Augment a built-in with a broken method. + r.augment('text', { + props: { + textBroken: { + type: r.fn(r.obj({}), r.text()), + get: { kind: 'get', path: [{ prop: 'unboundA' }] }, + }, + }, + }); + // Register an Extension with its own broken method. + const Point = r.extend('obj', { + name: 'Point', + props: { x: { type: r.num() }, y: { type: r.num() } }, + }); + r.register(Point); + r.augment('Point', { + props: { + pointBroken: { + type: r.fn(r.obj({}), r.num()), + get: { kind: 'get', path: [{ prop: 'unboundB' }] }, + }, + }, + }); + + const probs = r.validate(e); + // Both broken bodies surface, paths are prefixed with the type name. + const codes = probs.list.map((p) => p.code); + const paths = probs.list.map((p) => p.path[0]); + expect(codes.filter((c) => c === 'var.unknown').length).toBeGreaterThanOrEqual(2); + expect(paths).toContain('text'); + expect(paths).toContain('Point'); + }); +}); diff --git a/packages/gin/src/__tests__/validate-set.test.ts b/packages/gin/src/__tests__/validate-set.test.ts index 4fea5406..072ed893 100644 --- a/packages/gin/src/__tests__/validate-set.test.ts +++ b/packages/gin/src/__tests__/validate-set.test.ts @@ -80,9 +80,11 @@ describe('validate set — negative cases (errors flagged)', () => { expect(c).toContain('set.index.no-set'); }); - test('prop-set on a field with no PropDef.set', () => { + test('prop-set on a computed prop (has get, no set) → set.prop.computed', () => { const r = createRegistry(); - // An Extension that adds a prop with get only — no set. + // An Extension that adds a prop with `get` only — no `set`. Writing + // to it is a runtime impossibility because the read is computed; + // there's no underlying slot to hold a written value. r.register(r.extend('num', { name: 'readonly', props: { @@ -102,7 +104,7 @@ describe('validate set — negative cases (errors flagged)', () => { value: { kind: 'new', type: { name: 'num' }, value: 2 }, }, }); - expect(c).toContain('set.prop.no-set'); + expect(c).toContain('set.prop.computed'); }); test('method-call-set without call.set', () => { @@ -133,7 +135,7 @@ describe('validate set — negative cases (errors flagged)', () => { name: 'fn', value: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }], @@ -145,4 +147,62 @@ describe('validate set — negative cases (errors flagged)', () => { }); expect(c).toContain('set.call.no-set'); }); + + test('prop-set on a vanilla data field (no get, no set) — validates AND runs', async () => { + // The canonical "vars.foo = 'x'" case. A plain obj field with no + // get / set Expr is a data slot; writing to it should be allowed + // by the validator AND should actually update the raw value at + // runtime. Without the fall-through, ginny's `vars` global was + // unwritable from inside a gin program. + const { createRegistry, Engine } = await import('../index'); + const r = createRegistry(); + const e = new Engine(r); + + // Simulate ginny's `vars` global — an obj with one text-typed slot. + const varsType = r.obj({ apiKey: { type: r.text() } }); + e.registerGlobal('vars', { type: varsType, value: { apiKey: '' } }); + + // Validate: no `set.prop.computed` / `set.prop.method` flagged + // for a vanilla data field. + const probs = e.validate({ + kind: 'set', + path: [{ prop: 'vars' }, { prop: 'apiKey' }], + value: { kind: 'new', type: { name: 'text' }, value: 'sk-test' }, + }); + expect(probs.list.some((p) => p.code === 'set.prop.computed')).toBe(false); + expect(probs.list.some((p) => p.code === 'set.prop.method')).toBe(false); + + // Runtime: assignment should land on the underlying raw object. + await e.run({ + kind: 'set', + path: [{ prop: 'vars' }, { prop: 'apiKey' }], + value: { kind: 'new', type: { name: 'text' }, value: 'sk-test' }, + }); + const back = await e.run({ + kind: 'get', + path: [{ prop: 'vars' }, { prop: 'apiKey' }], + }); + expect(back.raw).toBe('sk-test'); + }); + + test('prop-set on a method-typed prop is NOT flagged (the call may have its own .set)', () => { + // A prop whose type is callable could still be writable via the + // call's own `set` mechanism, or via a custom prop-level `set` + // added by an extension. The validator doesn't have enough info + // at this step to decide, so it stays silent and lets the + // runtime surface a clear error if the assignment turns out to + // be impossible. + const e = new Engine(createRegistry()); + const c = codes(e, { + kind: 'define', + vars: [{ name: 'n', value: { kind: 'new', type: { name: 'num' }, value: 5 } }], + body: { + kind: 'set', + path: [{ prop: 'n' }, { prop: 'add' }], + value: { kind: 'new', type: { name: 'num' }, value: 0 }, + }, + }); + expect(c).not.toContain('set.prop.method'); + expect(c).not.toContain('set.prop.computed'); + }); }); diff --git a/packages/gin/src/analysis.ts b/packages/gin/src/analysis.ts index a363a7b8..4728194e 100644 --- a/packages/gin/src/analysis.ts +++ b/packages/gin/src/analysis.ts @@ -3,18 +3,19 @@ import type { Type } from './type'; import type { ExprDef } from './schema'; import { Problems } from './problem'; import { Expr, type ValidateContext } from './expr'; +import { RESERVED_NAMES } from './scope'; /** * Static type scope: name → runtime Type. Used by typeOf / validate to * reason about expression trees without executing them. */ -export type TypeScope = Map; +export type Locals = Map; /** * Infer the static result Type of an ExprDef (or parsed Expr) against a * type scope. Falls back to `any` on unknown parts — never throws. */ -export function typeOf(engine: Engine, expr: ExprDef | Expr, scope: TypeScope): Type { +export function typeOf(engine: Engine, expr: ExprDef | Expr, scope: Locals): Type { const e = expr instanceof Expr ? expr : parseExprSafe(engine, expr); if (!e) return engine.registry.any(); return e.typeOf(engine, scope); @@ -25,10 +26,21 @@ function parseExprSafe(engine: Engine, expr: ExprDef): Expr | undefined { catch { return undefined; } } -/** Top-level: walk an expression tree collecting Problems. Never throws. */ -export function validate(engine: Engine, expr: ExprDef | Expr, scope: TypeScope): Problems { +/** Top-level: walk an expression tree collecting Problems. Never throws. + * + * `ctx` lets callers mark the entry expression as already inside a + * loop or lambda — useful when validating a saved fn's body (where + * `return` is legal even though the body isn't wrapped in a + * LambdaExpr) or a snippet meant to run inside a loop. Defaults to + * the top-level program shape (neither loop nor lambda). */ +export function validate( + engine: Engine, + expr: ExprDef | Expr, + scope: Locals, + ctx: ValidateContext = { inLoop: false, inLambda: false }, +): Problems { const p = new Problems(); - walkValidate(engine, expr, scope, p, { inLoop: false, inLambda: false }); + walkValidate(engine, expr, scope, p, ctx); return p; } @@ -36,7 +48,7 @@ export function validate(engine: Engine, expr: ExprDef | Expr, scope: TypeScope) export function walkValidate( engine: Engine, expr: ExprDef | Expr, - scope: TypeScope, + scope: Locals, p: Problems, ctx: ValidateContext, ): Type { @@ -56,3 +68,38 @@ export function walkValidate( // Re-export ValidateContext for convenience. export type { ValidateContext } from './expr'; + +/** + * Validate a user-supplied binding name against the rules a `define` + * (or any other user-named scope binding) must follow: + * + * 1. Must not be a reserved name — gin's runtime injects those at + * well-known contexts (`args`, `recurse`, etc.); a user binding + * would be silently shadowed at runtime. + * 2. Must not already exist in `scope` — including names from outer + * scopes / globals. Disallowing this prevents accidental shadowing + * that produces confusing-at-runtime behavior (e.g. `define vars = + * ...` shadowing the persistent vars global). + * + * Pushes errors into `p`; never throws. Caller is expected to have + * already entered the relevant `at(...)` path. + */ +export function checkBindingName( + name: string, + scope: Locals, + p: Problems, +): void { + if (RESERVED_NAMES.has(name)) { + p.error( + 'binding.reserved', + `'${name}' is a reserved name (gin binds it automatically in fn/loop/path contexts) — pick a different name`, + ); + return; + } + if (scope.has(name)) { + p.error( + 'binding.shadow', + `'${name}' is already in scope — pick a different name to avoid shadowing`, + ); + } +} diff --git a/packages/gin/src/builder.ts b/packages/gin/src/builder.ts index 6d4daf45..0a4472fc 100644 --- a/packages/gin/src/builder.ts +++ b/packages/gin/src/builder.ts @@ -138,9 +138,8 @@ export interface TypeBuilder { // ─── interfaces ───────────────────────────────────────────────────────── iface(spec: IfaceSpec): Type; - // ─── references & generics ────────────────────────────────────────────── - ref(name: string): Type; - generic(name: string): Type; + // ─── aliases (former ref + generic, unified) ──────────────────────────── + alias(name: string): Type; // ─── extension ────────────────────────────────────────────────────────── extend(base: Type | string, local: ExtensionLocal): Extension; diff --git a/packages/gin/src/code.ts b/packages/gin/src/code.ts new file mode 100644 index 00000000..33e5cf28 --- /dev/null +++ b/packages/gin/src/code.ts @@ -0,0 +1,620 @@ +/** + * Structured code representation with spans tying every rendered range + * back to the node + validator path that produced it. + * + * Why this exists: today `engine.toCode(expr)` returns a flat string and + * `engine.validate(expr)` returns Problems with structural paths like + * `['vars', 0, 'value', 'ifs', 0, 'condition']`. The two are decoupled. + * The reader (LLM or human) has to manually map a path to its rendered + * position. With Code, every render call also produces a Span list: + * each span carries the same path the validator would emit, plus its + * char range in the rendered text. `formatProblem` then resolves + * `Problem.path → Span → (line, col)` and emits compiler-style output: + * + * const x: num = "wrong" + * ^^^^^^^ + * error: var 'x' value type 'text' not compatible with declared 'num' + * + * Storage is single-string + offset spans (not nested lines) — simpler + * to manipulate (concat, indent, replace) and `toLines()` derives the + * line view on demand. Multi-line spans cross newlines naturally. + * + * The primary builder is the `code\`...\`` tagged template. Interpolating + * `string` values appends them verbatim; interpolating `Code` values + * appends their text AND shifts their spans into the new combined + * range. A tree of nested `code\`...\`` calls accumulates a flat span + * list spanning the whole rendered text. + */ +import type { Expr } from './expr'; +import type { Type } from './type'; +import type { Problem, Problems } from './problem'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface Span { + /** Inclusive char offset into Code.text. */ + start: number; + /** Exclusive char offset. */ + end: number; + /** + * Validator-style structural path — same shape as `Problem.path`. + * Used by `Code.spanFor(target)` to resolve a problem to its + * rendered position via longest-prefix match. + */ + path: ReadonlyArray; + /** Optional back-reference to the node that produced this span. */ + expr?: Expr; + type?: Type; +} + +export interface CodeLine { + /** This line's text, without trailing newline. */ + text: string; + /** + * Spans intersecting this line, with offsets re-anchored to the + * line's start. A multi-line span appears once per line it covers, + * clipped to that line's range. + */ + spans: ReadonlyArray; + /** 1-based line number. */ + lineNum: number; +} + +// ─── Code class ──────────────────────────────────────────────────────────── + +export class Code { + constructor( + readonly text: string, + readonly spans: ReadonlyArray = [], + ) {} + + toString(): string { + return this.text; + } + + /** + * Append `other` to this Code. `other`'s spans get shifted by + * `this.text.length` so they continue to point at the right + * characters in the combined text. + */ + concat(other: Code | string): Code { + if (typeof other === 'string') { + return new Code(this.text + other, this.spans); + } + const offset = this.text.length; + const shifted = other.spans.map((s) => ({ + ...s, + start: s.start + offset, + end: s.end + offset, + })); + return new Code(this.text + other.text, [...this.spans, ...shifted]); + } + + /** + * Indent every line AFTER the first by `prefix`. Mirrors the existing + * `indentCode` string helper but re-anchors spans across the + * inserted whitespace. Spans that straddle a newline have their `end` + * shifted by the cumulative prefix length. + */ + indent(prefix: string): Code { + if (!prefix || !this.text.includes('\n')) return this; + // Build offset-shift map: for each char position, how many extra + // chars get inserted before it. Each `\n` adds `prefix.length` to + // every following position. + const len = this.text.length; + const shifts = new Int32Array(len + 1); // shifts[i] = total chars added before original position i + let cumulative = 0; + for (let i = 0; i < len; i++) { + shifts[i] = cumulative; + if (this.text[i] === '\n') cumulative += prefix.length; + } + shifts[len] = cumulative; + + const newText = this.text.replace(/\n/g, `\n${prefix}`); + const newSpans: Span[] = this.spans.map((s) => ({ + ...s, + start: s.start + shifts[s.start]!, + end: s.end + shifts[s.end]!, + })); + return new Code(newText, newSpans); + } + + /** + * Find the span whose `path` is the longest prefix of `target`. Used + * by `formatProblem` to map a Problem.path to its rendered range. + * Returns undefined when no span matches (caller falls back to a + * path-string format). + * + * Ties broken by smaller (more specific) char range — the smaller + * span is more localized and gives a tighter underline. + */ + spanFor(target: ReadonlyArray): Span | undefined { + let best: Span | undefined; + let bestPathLen = -1; + let bestRange = Number.POSITIVE_INFINITY; + for (const span of this.spans) { + if (!isPathPrefix(span.path, target)) continue; + const range = span.end - span.start; + if ( + span.path.length > bestPathLen || + (span.path.length === bestPathLen && range < bestRange) + ) { + best = span; + bestPathLen = span.path.length; + bestRange = range; + } + } + return best; + } + + /** + * Split into lines. Each line carries its own `spans` array with + * offsets re-anchored to the line's start. Multi-line spans appear + * in EVERY line they intersect, clipped to that line's char range. + */ + toLines(): CodeLine[] { + const out: CodeLine[] = []; + const lines = this.text.split('\n'); + let cursor = 0; + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]!; + const lineStart = cursor; + const lineEnd = cursor + lineText.length; + const lineSpans: Span[] = []; + for (const s of this.spans) { + if (s.end <= lineStart || s.start >= lineEnd) continue; + const start = Math.max(s.start, lineStart) - lineStart; + const end = Math.min(s.end, lineEnd) - lineStart; + lineSpans.push({ ...s, start, end }); + } + out.push({ text: lineText, spans: lineSpans, lineNum: i + 1 }); + cursor = lineEnd + 1; // +1 for the consumed `\n` + } + return out; + } +} + +// ─── Builders ────────────────────────────────────────────────────────────── + +/** + * Plain-text Code with no spans. Equivalent to `new Code(text)`. + * Convenience for callers that want to mix string content into a + * `code\`...\`` chain without losing type uniformity. + */ +export function plain(text: string): Code { + return new Code(text); +} + +/** + * Wrap an inner Code (or string) with an outer span covering the + * entire text. Child spans (if `inner` is already a Code) are + * preserved beneath. Use this at every node-level toGinCode/toJSONCode + * boundary to attach the node's own path + back-reference. + */ +export function span( + inner: Code | string, + meta: { path: ReadonlyArray; expr?: Expr; type?: Type }, +): Code { + const innerCode = typeof inner === 'string' ? new Code(inner) : inner; + const outer: Span = { + start: 0, + end: innerCode.text.length, + path: meta.path, + expr: meta.expr, + type: meta.type, + }; + // Outer span first so it's iterated before the inner ones — affects + // tie-breaking only marginally (longest-prefix wins regardless). + return new Code(innerCode.text, [outer, ...innerCode.spans]); +} + +/** + * Tagged template — primary builder. Interpolates strings and Codes + * into a single Code, shifting child spans to their position in the + * combined text. Newlines and indentation are preserved verbatim; + * use `.indent(prefix)` on a Code for line-relative reindentation. + * + * Example: + * const head = this.value.toGinCode(reg, opts, [...path, 'value']); + * return code`switch (${head}) {\n${body}\n}`; + */ +export function code( + strings: TemplateStringsArray, + ...values: ReadonlyArray +): Code { + let text = ''; + const spans: Span[] = []; + for (let i = 0; i < strings.length; i++) { + text += strings[i]; + if (i < values.length) { + const v = values[i]!; + if (typeof v === 'string') { + text += v; + } else { + const offset = text.length; + text += v.text; + for (const s of v.spans) { + spans.push({ ...s, start: s.start + offset, end: s.end + offset }); + } + } + } + } + return new Code(text, spans); +} + +/** + * Join an array of Code (or string) values with a separator into one + * Code. Like `Array.prototype.join` but span-preserving. Common + * pattern: rendering a list of cases / lines / fields. + */ +export function joinCode(parts: ReadonlyArray, sep: string | Code = ''): Code { + if (parts.length === 0) return new Code(''); + const sepCode = typeof sep === 'string' ? new Code(sep) : sep; + let result = typeof parts[0] === 'string' ? new Code(parts[0]) : parts[0]!; + for (let i = 1; i < parts.length; i++) { + result = result.concat(sepCode).concat(parts[i]!); + } + return result; +} + +/** + * Convenience: `joinCode(parts, '\n')`. Used heavily by composite + * Exprs that emit one rendered line per child (block lines, define + * vars, switch cases, …). + */ +export function joinLines(parts: ReadonlyArray): Code { + return joinCode(parts, '\n'); +} + +// ─── JSON builders (for `toJSONCode` overrides) ────────────────────────────── + +export interface JSONEntry { + key: string; + /** Field value rendered as Code (or pre-stringified primitive). When + * `undefined`, the entry is OMITTED — matches `JSON.stringify`'s + * behaviour of dropping `undefined` properties. */ + value: Code | string | undefined; +} + +/** + * Build a JSON object literal as a `Code` with the same indentation + * shape `JSON.stringify(obj, null, indent)` produces. Each entry's + * `value` is appended verbatim, so child `Code` values must already + * be rendered for the matching depth (`level + 1`). The outer + * `{ ... }` block is wrapped in a single span tagged with `meta`. + * + * Empty objects render as `{}` on one line, like JSON.stringify. + */ +export function jsonObject( + entries: ReadonlyArray, + meta: { path: ReadonlyArray; expr?: Expr; type?: Type }, + level: number = 0, + indent: number = 2, +): Code { + const filtered = entries.filter((e) => e.value !== undefined); + if (filtered.length === 0) return span('{}', meta); + const childIndent = ' '.repeat((level + 1) * indent); + const closeIndent = ' '.repeat(level * indent); + const lines: Code[] = filtered.map(({ key, value }) => { + const valueCode = typeof value === 'string' ? new Code(value) : value as Code; + return code`${childIndent}${JSON.stringify(key)}: ${valueCode}`; + }); + return span(code`{\n${joinCode(lines, ',\n')}\n${closeIndent}}`, meta); +} + +/** + * Build a JSON array literal. Items are rendered verbatim, comma-and- + * newline separated, with `(level + 1) * indent` spaces of leading + * indent on each item. + */ +export function jsonArray( + items: ReadonlyArray, + meta: { path: ReadonlyArray; expr?: Expr; type?: Type }, + level: number = 0, + indent: number = 2, +): Code { + if (items.length === 0) return span('[]', meta); + const childIndent = ' '.repeat((level + 1) * indent); + const closeIndent = ' '.repeat(level * indent); + const itemCodes: Code[] = items.map((it) => { + const c = typeof it === 'string' ? new Code(it) : it; + return code`${childIndent}${c}`; + }); + return span(code`[\n${joinCode(itemCodes, ',\n')}\n${closeIndent}]`, meta); +} + +/** Quote-and-escape a JSON string value. Convenience for tagged-template + * callers that want an inline literal without leaving the `code`/`span` + * builder world. */ +export function jsonString(value: string): string { + return JSON.stringify(value); +} + +// ─── Error formatting ────────────────────────────────────────────────────── + +const ANSI = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; + +const SEVERITY_LABEL: Record = { + error: 'error', + warning: 'warning', + info: 'info', +}; + +const SEVERITY_COLOR: Record = { + error: ANSI.red, + warning: ANSI.yellow, + info: ANSI.blue, +}; + +export interface FormatOptions { + /** Emit ANSI color codes. Default false. */ + color?: boolean; +} + +export interface FormatProblemsOptions extends FormatOptions { + /** Number of context lines above/below each problem's span. Adjacent + * problems whose context windows touch get merged into one section. + * Default 2. */ + contextLines?: number; + /** Show `── lines N-M ───` header above each merged section. Default true. */ + sectionHeaders?: boolean; + /** Show 1-based line-number gutter (` 5 │ `). Default true. */ + lineNumbers?: boolean; + /** Cap on the number of problems rendered. Default Infinity. */ + maxProblems?: number; +} + +interface ResolvedProblem { + problem: Problem; + firstLine: number; + lastLine: number; + /** Map from line index → that line's clipped (startCol, endCol) of the span. */ + hits: Map; +} + +interface Section { + firstLine: number; + lastLine: number; + problems: ResolvedProblem[]; +} + +/** + * Render one Problem against `code`. Convenience wrapper that builds a + * one-element Problems-like list and delegates to the section renderer + * so the output stays consistent with `formatProblems`. + */ +export function formatProblem( + code: Code, + problem: Problem, + opts: FormatOptions = {}, +): string { + return renderProblems(code, [problem], { + ...opts, + // Single-problem renders default to no section header / no line + // numbers — matches the older terse output callers expect. + sectionHeaders: false, + lineNumbers: false, + contextLines: 0, + }); +} + +/** + * Render every Problem against `code` as a sequence of sections. Each + * section is a contiguous block of source lines containing one or more + * problems plus a configurable buffer of surrounding context. Sections + * whose context windows overlap are merged so problems near each other + * share their surrounding code instead of repeating it. + * + * Output shape (with defaults): + * + * ── lines 5-7 ─────────────────── + * 5 │ const x: num = "wrong"; + * ^^^^^^^ + * error: var 'x' value type 'text' not compatible with declared 'num' + * 6 │ x; + * 7 │ } + * + * ── lines 12-14 ────────────────── + * 12 │ if (1) { + * ^ + * warning: if condition should be bool, got 'num' + * 13 │ x; + * 14 │ } + * + * Problems whose path resolves to no span fall through to a plain + * `: @ ` line appended after the sections. + */ +export function formatProblems( + code: Code, + problems: Problems, + opts: FormatProblemsOptions = {}, +): string { + return renderProblems(code, problems.list, opts); +} + +function renderProblems( + code: Code, + list: ReadonlyArray, + opts: FormatProblemsOptions, +): string { + if (list.length === 0) return ''; + const color = opts.color ?? false; + const contextLines = opts.contextLines ?? 2; + const sectionHeaders = opts.sectionHeaders ?? true; + const lineNumbers = opts.lineNumbers ?? true; + const max = opts.maxProblems ?? Number.POSITIVE_INFINITY; + + const c = (ansi: string, s: string): string => (color ? `${ansi}${s}${ANSI.reset}` : s); + const lines = code.toLines(); + + const resolved: ResolvedProblem[] = []; + const fallback: string[] = []; + let suppressed = 0; + for (let i = 0; i < list.length; i++) { + if (i >= max) { suppressed = list.length - i; break; } + const p = list[i]!; + const r = resolveProblem(code, lines, p); + if (r) { + resolved.push(r); + } else { + const sevColor = SEVERITY_COLOR[p.severity]; + const sevLabel = `${SEVERITY_LABEL[p.severity]}:`; + const pathStr = p.path.length > 0 ? ` @ ${p.path.join('.')}` : ''; + fallback.push(`${c(sevColor, sevLabel)} ${p.message}${pathStr}`); + } + } + + const sections = mergeSections(resolved, lines.length, contextLines); + const totalLines = lines.length; + const gutterWidth = lineNumbers ? String(totalLines).length : 0; + + const blocks: string[] = sections.map((section) => + renderSection(section, lines, { color, sectionHeaders, lineNumbers, gutterWidth, c }), + ); + if (fallback.length > 0) blocks.push(fallback.join('\n')); + if (suppressed > 0) blocks.push(`… (${suppressed} more problem${suppressed === 1 ? '' : 's'} suppressed)`); + + return blocks.join('\n\n'); +} + +/** Resolve a Problem to its line/column hits across the rendered code. */ +function resolveProblem(code: Code, lines: CodeLine[], problem: Problem): ResolvedProblem | null { + const matched = code.spanFor(problem.path); + if (!matched) return null; + const hits = new Map(); + let cursor = 0; + let firstLine = -1; + let lastLine = -1; + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]!.text; + const lineStart = cursor; + const lineEnd = cursor + lineText.length; + if (matched.end > lineStart && matched.start <= lineEnd) { + const startCol = Math.max(matched.start, lineStart) - lineStart; + const endCol = Math.min(matched.end, lineEnd) - lineStart; + hits.set(i, { startCol, endCol }); + if (firstLine < 0) firstLine = i; + lastLine = i; + } + cursor = lineEnd + 1; + } + if (firstLine < 0) return null; + return { problem, firstLine, lastLine, hits }; +} + +/** Group resolved problems into sections of contiguous lines. Each + * problem's lines are extended by `contextLines` above/below; sections + * whose extended ranges touch get merged and accumulate their problems. */ +function mergeSections( + resolved: ReadonlyArray, + totalLines: number, + contextLines: number, +): Section[] { + if (resolved.length === 0) return []; + const sorted = [...resolved].sort((a, b) => a.firstLine - b.firstLine); + const sections: Section[] = []; + for (const r of sorted) { + const start = Math.max(0, r.firstLine - contextLines); + const end = Math.min(totalLines - 1, r.lastLine + contextLines); + const last = sections[sections.length - 1]; + if (last && start <= last.lastLine + 1) { + // Adjacent / overlapping windows merge into the prior section. + last.lastLine = Math.max(last.lastLine, end); + last.problems.push(r); + } else { + sections.push({ firstLine: start, lastLine: end, problems: [r] }); + } + } + return sections; +} + +function renderSection( + section: Section, + lines: CodeLine[], + opts: { + color: boolean; + sectionHeaders: boolean; + lineNumbers: boolean; + gutterWidth: number; + c: (ansi: string, s: string) => string; + }, +): string { + const { color, sectionHeaders, lineNumbers, gutterWidth, c } = opts; + const out: string[] = []; + + const numberedGutter = (n: number): string => + lineNumbers ? c(ANSI.dim, `${String(n).padStart(gutterWidth)} │ `) : ''; + const blankGutter = (): string => + lineNumbers ? c(ANSI.dim, `${' '.repeat(gutterWidth)} │ `) : ''; + + if (sectionHeaders) { + const range = section.firstLine === section.lastLine + ? `line ${section.firstLine + 1}` + : `lines ${section.firstLine + 1}-${section.lastLine + 1}`; + const dashes = '─'.repeat(Math.max(3, 60 - range.length - 4)); + out.push(c(ANSI.dim, `── ${range} ${dashes}`)); + } + + // Severity ordering — when multiple problems share an identical + // (startCol, endCol) range on a line, the underline is rendered once + // colored by the most severe of the group. Index = sort key (lower + // wins → more severe). + const SEV_RANK: Record = { error: 0, warning: 1, info: 2 }; + + for (let i = section.firstLine; i <= section.lastLine; i++) { + out.push(numberedGutter(i + 1) + lines[i]!.text); + + // Dedupe underlines on this line: multiple problems whose spans + // collapse to the SAME (startCol, endCol) on this line share one + // underline instead of stacking 5 identical `^^^^` rows. The most + // severe color wins. (Different ranges still render separately.) + const seenRanges = new Map(); + for (const p of section.problems) { + const hit = p.hits.get(i); + if (!hit) continue; + const key = `${hit.startCol}:${hit.endCol}`; + const existing = seenRanges.get(key); + if (!existing || SEV_RANK[p.problem.severity] < SEV_RANK[existing]) { + seenRanges.set(key, p.problem.severity); + } + } + for (const [key, sev] of seenRanges) { + const [startStr, endStr] = key.split(':'); + const startCol = Number(startStr); + const endCol = Number(endStr); + const underline = ' '.repeat(startCol) + '^'.repeat(Math.max(1, endCol - startCol)); + out.push(blankGutter() + c(SEVERITY_COLOR[sev], underline)); + } + + // Messages: every problem whose span ENDS on this line gets its + // severity-prefixed message immediately under its underline. This + // keeps the message anchored to the bottom of its underlined block, + // which reads naturally for both single-line and multi-line spans. + for (const p of section.problems) { + if (p.lastLine !== i) continue; + const sev = p.problem.severity; + const label = c(SEVERITY_COLOR[sev], `${SEVERITY_LABEL[sev]}:`); + out.push(blankGutter() + `${label} ${p.problem.message}`); + } + } + + return out.join('\n'); +} + +// ─── Path matching helpers ───────────────────────────────────────────────── + +function isPathPrefix(prefix: ReadonlyArray, target: ReadonlyArray): boolean { + if (prefix.length > target.length) return false; + for (let i = 0; i < prefix.length; i++) { + if (prefix[i] !== target[i]) return false; + } + return true; +} diff --git a/packages/gin/src/engine.ts b/packages/gin/src/engine.ts index e9a2388e..c23f8c2a 100644 --- a/packages/gin/src/engine.ts +++ b/packages/gin/src/engine.ts @@ -7,9 +7,10 @@ import type { Type } from './type'; import type { ExprDef } from './schema'; import { Expr } from './expr'; import type { CodeOptions } from './node'; +import type { Code } from './code'; import { ExitSignal } from './flow-control'; -import { typeOf as typeOfAnalysis, validate as validateAnalysis, type TypeScope } from './analysis'; +import { typeOf as typeOfAnalysis, validate as validateAnalysis, type Locals } from './analysis'; import { Problems } from './problem'; /** @@ -87,7 +88,7 @@ export class Engine { * Infer the static return type of an expression against a type scope. * Returns `any` on unknown parts — never throws. */ - typeOf(expr: ExprDef | Expr, scope?: TypeScope): Type { + typeOf(expr: ExprDef | Expr, scope?: Locals): Type { const s = scope ?? this.globalTypeScope(); return typeOfAnalysis(this, expr, s); } @@ -95,10 +96,19 @@ export class Engine { /** * Walk an expression tree and collect Problems (unknown vars, unknown * props / natives, out-of-place break/return, etc.). Never throws. + * + * `ctx` lets the caller mark the root as already inside a lambda or + * loop — needed when validating a saved fn's body (the body has + * `args`/`recurse` bound and `return` is legal there even though + * there's no enclosing LambdaExpr). Defaults to top-level shape. */ - validate(expr: ExprDef | Expr, scope?: TypeScope): Problems { + validate( + expr: ExprDef | Expr, + scope?: Locals, + ctx?: import('./expr').ValidateContext, + ): Problems { const s = scope ?? this.globalTypeScope(); - return validateAnalysis(this, expr, s); + return validateAnalysis(this, expr, s, ctx); } /** @@ -109,8 +119,29 @@ export class Engine { return this.registry.toCode(expr, options); } - /** A TypeScope seeded with the registered globals' declared types. */ - globalTypeScope(): TypeScope { + /** + * Render an ExprDef as gin TS-pseudocode with span annotations + * tying each rendered range back to its node + validator path. + * Pair the result with `Problems` from `validate(...)` and feed + * both to `formatProblem` / `formatProblems` (in `./code`) to get + * compiler-style `^^^` error pointers. + */ + toGinCode(expr: ExprDef | Expr, options?: CodeOptions): Code { + return this.registry.toGinCode(expr, options); + } + + /** + * Render an ExprDef as its JSON form (matching + * `JSON.stringify(expr.toJSON(), null, 2)`) with spans aligned to + * structural positions. Lets callers surface validation errors in + * the JSON the LLM actually wrote. + */ + toJSONCode(expr: ExprDef | Expr, indent: number = 2): Code { + return this.registry.toJSONCode(expr, indent); + } + + /** A Locals seeded with the registered globals' declared types. */ + globalTypeScope(): Locals { const m = new Map(); for (const [name, g] of this.globals) m.set(name, g.type); return m; diff --git a/packages/gin/src/expr.ts b/packages/gin/src/expr.ts index 11fcd7f0..6d389639 100644 --- a/packages/gin/src/expr.ts +++ b/packages/gin/src/expr.ts @@ -4,10 +4,12 @@ import type { Value } from './value'; import type { Type } from './type'; import type { Registry } from './registry'; import type { ExprDef } from './schema'; -import type { TypeScope } from './analysis'; +import type { Locals } from './analysis'; import { Problems } from './problem'; import type { Node, CodeOptions, SchemaOptions } from './node'; +import { Code, span } from './code'; import type { z } from 'zod'; +import type { TypeScope } from './type-scope'; /** * Context flags carried through validate walks so handlers can report @@ -63,10 +65,25 @@ export abstract class Expr implements Node { return this; } + /** + * Whether this Expr's comment should render as a line comment + * (`// foo\n` on the line above) rather than an inline block + * (`/* foo *\/ expr`). Defaults to "line in statement context, inline + * in value context". Multi-line / statement-shaped Exprs (define / + * if / switch / block / lambda / loop / flow / set) override this to + * force the line form even when used in value position — a stacked + * `// note` reads better above a multi-line construct than a stray + * inline block at its head. + */ + protected useLineComment(options: CodeOptions = {}): boolean { + return options.expectsValue === false; + } + /** Rendered comment prefix for toCode. */ protected commentPrefix(options: CodeOptions = {}): string { if (!this.comment) return ''; - return options.expectsValue === false + if (options.includeComments === false) return ''; + return this.useLineComment(options) ? `// ${this.comment}\n` : `/* ${this.comment} */ `; } @@ -80,25 +97,111 @@ export abstract class Expr implements Node { abstract evaluate(engine: Engine, scope: Scope): Promise; /** Infer the static return Type against a type scope. */ - abstract typeOf(engine: Engine, scope: TypeScope): Type; + abstract typeOf(engine: Engine, scope: Locals): Type; /** * Recursive validation walk — accumulates Problems into `p` and returns * the inferred Type. Called by child exprs during validate walks. * Use `validate(engine)` for the clean top-level entry. */ - abstract validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type; + abstract validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type; /** Top-level entry: walk collecting Problems. Mirrors Type.validate. */ - validate(engine: Engine, scope?: TypeScope): Problems { + validate(engine: Engine, scope?: Locals): Problems { const p = new Problems(); const s = scope ?? engine.globalTypeScope(); this.validateWalk(engine, s, p, { inLoop: false, inLambda: false }); return p; } - /** Render as TypeScript-like source text. See CodeOptions.expectsValue. */ - abstract toCode(registry?: Registry, options?: CodeOptions): string; + /** + * Render as TypeScript-like source text. See CodeOptions.expectsValue. + * The default implementation delegates to `toGinCode(...).toString()`, + * so subclasses can override either method — concrete classes + * historically override `toCode` directly with string concatenation, + * but newer / migrated classes override `toGinCode` to gain spans. + */ + toCode(registry?: Registry, options?: CodeOptions): string { + return this.toGinCode(registry, options).toString(); + } + + /** + * Render as gin's TS-pseudocode form as a structured `Code` value + * carrying spans tied to validator paths. Default: wrap the legacy + * string-returning `toCode` output in a single coarse span covering + * the whole text. Composite classes that the validator targets with + * structural paths (block, define, if, switch, get, …) override this + * to thread `[...path, segment]` into each child's `toGinCode` call, + * producing fine-grained spans. + * + * Subclasses that have NOT been migrated yet keep returning the + * existing `toCode` result wrapped in a coarse span — every consumer + * still works, error pointers are just less precise (point at the + * whole node rather than a nested field) until the override lands. + */ + toGinCode( + registry?: Registry, + options?: CodeOptions, + path: ReadonlyArray = [], + ): Code { + // The base reaches into `toCode` even though `toCode` defaults to + // `toGinCode().toString()`. To avoid infinite recursion when a + // subclass overrides NEITHER, fall back to the abstract `_toCode` + // helper that subclasses MUST provide. In practice every subclass + // currently overrides `toCode`, so this branch is reached only + // through explicit `super.toGinCode` calls (which we don't make). + const text = this._toCodeFallback(registry, options); + return span(text, { path, expr: this }); + } + + /** + * Default JSON-form rendering. Returns the indented JSON of + * `toJSON()` wrapped in a coarse single span. Subclasses override + * to thread child paths through. + * + * `level > 0` re-indents continuation lines so when this Code is + * embedded as a child of a composite renderer the indentation + * matches `JSON.stringify`'s shape exactly. The first line is + * never re-indented (the parent positions the opening `{` / `[` + * itself). + */ + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + let text = JSON.stringify(this.toJSON(), null, indent); + if (level > 0) { + const lead = ' '.repeat(level * indent); + text = text.replace(/\n/g, '\n' + lead); + } + return span(text, { path, expr: this }); + } + + /** + * Internal hook for the base `toGinCode` to reach the subclass's + * legacy string render without re-entering `toCode` (which delegates + * back to us). Subclasses that still ship a string-form override + * keep their `toCode` definition; this base just calls into it + * through a stable name. + * + * The default forwards to `toCode` if a subclass HAS overridden + * `toCode` (the historical pattern). Subclasses that override + * `toGinCode` directly never hit this path. + */ + protected _toCodeFallback(registry?: Registry, options?: CodeOptions): string { + // Subclasses that haven't yet been migrated still override `toCode` + // with their own string-builder. Calling this.toCode would recurse + // because `Expr.toCode` defaults to toGinCode().toString(). To + // bridge, look up the prototype's own `toCode` — if it's not the + // base default, call it; otherwise emit a placeholder. + const proto = Object.getPrototypeOf(this) as { toCode?: typeof Expr.prototype.toCode }; + const own = proto.toCode; + if (own && own !== Expr.prototype.toCode) { + return own.call(this, registry, options) as string; + } + return ``; + } /** Serialize back to the JSON ExprDef shape (inverse of static from). */ abstract toJSON(): ExprDef; @@ -121,7 +224,11 @@ export abstract class Expr implements Node { */ export interface ExprClass { readonly KIND: string; - from(json: ExprDef, registry: Registry): Expr; + /** Build an Expr from its JSON. `scope` is the type-name resolution + * scope used when recursing into nested TypeDefs (for `new`, + * `lambda`, `native`, `define`). Use `scope.registry` to access the + * underlying Registry. */ + from(json: ExprDef, scope: TypeScope): Expr; /** JSON-shape Zod schema for this Expr's ExprDef. */ toSchema(opts: SchemaOptions): z.ZodTypeAny; } diff --git a/packages/gin/src/exprs/block.ts b/packages/gin/src/exprs/block.ts index 349e549b..9c1084cd 100644 --- a/packages/gin/src/exprs/block.ts +++ b/packages/gin/src/exprs/block.ts @@ -4,14 +4,16 @@ import type { BlockExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode } from './code'; +import { Code, code, span, joinLines, jsonObject, jsonArray, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * BlockExpr — sequence of expressions; last one's value is the result. @@ -24,15 +26,22 @@ export class BlockExpr extends Expr { super(); } - static from(json: BlockExprDef, registry: Registry): BlockExpr { - return new BlockExpr(json.lines.map((l) => registry.parseExpr(l))).withComment(json.comment); + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: BlockExprDef, scope: TypeScope): BlockExpr { + const r = scope.registry; + return new BlockExpr(json.lines.map((l) => r.parseExpr(l, scope))).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('block'), ...baseExprFields, - lines: z.array(opts.Expr), + lines: z + .array(opts.Expr) + .describe( + 'Sequence of expressions evaluated in order. The block\'s value is the LAST line\'s value (an empty block returns void). Earlier lines run for their side effects (set, fns.fetch, etc.).', + ), }).meta({ aid: 'Expr_block' }); } @@ -44,12 +53,12 @@ export class BlockExpr extends Expr { return last; } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { if (this.lines.length === 0) return engine.registry.void(); return typeOf(engine, this.lines[this.lines.length - 1]!, scope); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { let last: Type = engine.registry.void(); for (let i = 0; i < this.lines.length; i++) { last = p.at(i, () => walkValidate(engine, this.lines[i]!, scope, p, ctx)); @@ -57,39 +66,73 @@ export class BlockExpr extends Expr { return last; } - toCode(registry?: Registry, options: CodeOptions = {}): string { + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { const expectsValue = options.expectsValue ?? false; - if (this.lines.length === 0) return expectsValue ? 'undefined' : ''; + if (this.lines.length === 0) { + return span(expectsValue ? 'undefined' : '', { path, expr: this }); + } if (this.lines.length === 1) { - return this.lines[0]!.toCode(registry, options); + return span( + this.lines[0]!.toGinCode(registry, options, [...path, 0]), + { path, expr: this }, + ); } const prefix = this.commentPrefix(options); if (expectsValue) { - const body = this.lines.map((line, i) => { + // Value-form: IIFE wrapper. Each line becomes ` return X;` or + // ` X;` depending on position. Each child renders with its own + // path-suffixed span so the validator's per-line `i` maps back + // to the right rendered range. + const lineBodies = this.lines.map((line, i) => { const isLast = i === this.lines.length - 1; - const code = line.toCode(registry, { expectsValue: isLast }); - return isLast ? ` return ${indentCode(code)};` : ` ${indentCode(code)};`; - }).join('\n'); - return prefix + `(() => {\n${body}\n})()`; + const c = line.toGinCode(registry, { ...options, expectsValue: isLast }, [...path, i]).indent(' '); + return isLast ? code` return ${c};` : code` ${c};`; + }); + const body = joinLines(lineBodies); + return span(code`${prefix}(() => {\n${body}\n})()`, { path, expr: this }); } - const body = this.lines.map((line) => { + // Statement-form: lines joined by newlines, no surrounding braces. + const parts = this.lines.map((line, i) => { const kind = (line as { kind: string }).kind; - const code = line.toCode(registry, { expectsValue: false }); - if (kind === 'if' || kind === 'switch' || kind === 'loop' || kind === 'block') { - return ` ${indentCode(code)}`; - } - return ` ${indentCode(code)};`; - }).join('\n'); - return prefix + `{\n${body}\n}`; + const c = line.toGinCode(registry, { ...options, expectsValue: false }, [...path, i]); + // Trailing `;` only for plain expressions / sets / defines — + // control-flow statements self-terminate. + if (kind === 'if' || kind === 'switch' || kind === 'loop' || kind === 'block') return c; + return code`${c};`; + }); + return span(code`${prefix}${joinLines(parts)}`, { path, expr: this }); } toJSON(): BlockExprDef { return this.withCommentOn({ kind: 'block', lines: this.lines.map((l) => l.toJSON()) }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const childItems = this.lines.map((line, i) => + line.toJSONCode([...path, i], indent, level + 2)); + return jsonObject( + [ + { key: 'kind', value: jsonString('block') }, + { key: 'lines', value: jsonArray(childItems, { path: [...path, 'lines'] }, level + 1, indent) }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): BlockExpr { return new BlockExpr(this.lines.map((l) => l.clone())).withComment(this.comment); } @@ -98,3 +141,4 @@ export class BlockExpr extends Expr { for (const line of this.lines) visit(line, 'inherit'); } } + diff --git a/packages/gin/src/exprs/code.ts b/packages/gin/src/exprs/code.ts index a6dc0e6c..df877680 100644 --- a/packages/gin/src/exprs/code.ts +++ b/packages/gin/src/exprs/code.ts @@ -1,6 +1,8 @@ import type { Registry } from '../registry'; +import type { CodeOptions } from '../node'; import { Expr, type ChildBoundary } from '../expr'; import { FlowExpr } from './flow'; +import { Code, code, plain } from '../code'; /** Indent every line after the first by two spaces (for multi-line bodies). */ export function indentCode(code: string): string { @@ -41,26 +43,78 @@ export function findEscapingFlow(expr: Expr, enclosing: ChildBoundary = 'inherit /** * Render an Expr as a statement-body for an if/else/for/switch branch. - * Flow statements render bare with a trailing `;`. Blocks render as - * already-braced statement sequences. Everything else is wrapped in - * `{ ...; }` so the containing control structure reads cleanly. + * + * Always emits a multi-line braced form so all branches read uniformly + * (no mixing of `} else { x; }` single-liners with multi-line if-bodies). + * Special cases: + * - `flow` (return / break / continue / throw / exit) renders bare + * plus `;` — `else return x;` is more readable than wrapping in + * braces just to terminate. + * - sub-`if` / `switch` / `loop` render in their own statement form + * (already self-bracing); used by `else if (...)` chains. + * - `block` emits its lines bare (BlockExpr no longer self-braces in + * statement form), so the wrapper here adds the `{` / `}`. + * - everything else: an expression statement wrapped in braces. */ -export function renderStatementBody(expr: Expr, registry?: Registry): string { - // Import lazily via require-esque pattern would create cycles; use - // structural markers instead of instanceof here to avoid the import. - // FlowExpr: render bare + `;`. BlockExpr: already braces itself in - // statement mode, reuse as-is. +export function renderStatementBody(expr: Expr, registry?: Registry, options: CodeOptions = {}): string { + // Use structural markers instead of instanceof to avoid a circular + // import on the concrete Expr classes. const kind = (expr as { kind: string }).kind; if (kind === 'flow') { - return `${expr.toCode(registry, { expectsValue: false })};`; + return `${expr.toCode(registry, { ...options, expectsValue: false })};`; + } + if (kind === 'if' || kind === 'switch' || kind === 'loop') { + return expr.toCode(registry, { ...options, expectsValue: false }); } if (kind === 'block') { - const code = expr.toCode(registry, { expectsValue: false }); + const code = expr.toCode(registry, { ...options, expectsValue: false }); return code.startsWith('{') ? code : `{\n ${indentCode(code)}\n}`; } + // Expression statement — wrap in multi-line braces so the rendered + // code stays uniform across branch sizes. + return `{\n ${indentCode(expr.toCode(registry, { ...options, expectsValue: true }))};\n}`; +} + +/** + * `Code`-aware variant of `renderStatementBody` — same semantics, but + * the body's spans flow through to the caller. The caller passes the + * `path` prefix where `expr` sits in its parent (e.g. `[...path, 'ifs', + * i, 'body']`); the body's child spans are produced relative to that. + * + * Mirrors the string variant's branch logic exactly so call sites can + * be migrated 1:1. + */ +export function renderStatementBodyRich( + expr: Expr, + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], +): Code { + const kind = (expr as { kind: string }).kind; + if (kind === 'flow') { + const inner = expr.toGinCode(registry, { ...options, expectsValue: false }, path); + return code`${inner};`; + } if (kind === 'if' || kind === 'switch' || kind === 'loop') { - return expr.toCode(registry, { expectsValue: false }); + return expr.toGinCode(registry, { ...options, expectsValue: false }, path); + } + if (kind === 'block') { + const body = expr.toGinCode(registry, { ...options, expectsValue: false }, path); + return body.text.startsWith('{') + ? body + : code`{\n ${body.indent(' ')}\n}`; } - // Expression statement — wrap in braces + `;`. - return `{ ${expr.toCode(registry, { expectsValue: true })}; }`; + // Expression statement — wrap in multi-line braces. + const inner = expr.toGinCode(registry, { ...options, expectsValue: true }, path); + return code`{\n ${inner.indent(' ')};\n}`; +} + +/** `Code.indent` thin wrapper for callers that already have a Code. */ +export function indentCodeRich(c: Code, prefix: string = ' '): Code { + return c.indent(prefix); } + +// `plain` is exported for callers that need to mix bare strings into a +// `code\`...\`` chain without losing typing — keep it re-exported so +// composite renderers don't need to import from `../code` directly. +export { plain }; diff --git a/packages/gin/src/exprs/define.ts b/packages/gin/src/exprs/define.ts index 6d6f0987..b2bc6a5c 100644 --- a/packages/gin/src/exprs/define.ts +++ b/packages/gin/src/exprs/define.ts @@ -4,14 +4,16 @@ import type { DefineExprDef, TypeDef } from '../schema'; import type { Value } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; -import { typeOf, walkValidate } from '../analysis'; +import type { Locals } from '../analysis'; +import { checkBindingName, typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; +import { Code, code, span, joinLines, jsonObject, jsonArray, jsonString } from '../code'; import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode } from './code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * DefineExpr — introduce local bindings into a child scope, then evaluate body. @@ -32,24 +34,37 @@ export class DefineExpr extends Expr { super(); } - static from(json: DefineExprDef, registry: Registry): DefineExpr { + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: DefineExprDef, scope: TypeScope): DefineExpr { + const r = scope.registry; const vars: DefineVar[] = json.vars.map((v) => ({ name: v.name, - type: v.type ? registry.parse(v.type) : undefined, - value: registry.parseExpr(v.value), + type: v.type ? r.parse(v.type, scope) : undefined, + value: r.parseExpr(v.value, scope), })); - return new DefineExpr(vars, registry.parseExpr(json.body)).withComment(json.comment); + return new DefineExpr(vars, r.parseExpr(json.body, scope)).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('define'), ...baseExprFields, - vars: z.array(z.object({ - name: z.string(), - type: opts.Type.optional(), - value: opts.Expr, - })), + vars: z + .array( + z.object({ + name: z.string().describe( + 'Variable name. Must NOT be a reserved name (args, recurse, this, super, key, value, yield, error) and must NOT shadow anything already in scope.', + ), + type: opts.Type.optional().describe( + 'Optional declared type. OMIT this field when the value already determines the type — every value Expr (`new`, `get`, `if`, ...) is typed, and the var inherits that type. Set this only when you need to widen / narrow / annotate beyond what the value alone produces; a mismatch with the value\'s inferred type is reported as `define.var.type-mismatch`.', + ), + value: opts.Expr.describe( + "The expression whose result is bound under `name`. May reference any earlier var in this define — each var is added to scope before the next var's value is evaluated.", + ), + }), + ) + .describe('Bindings introduced before `body`. Evaluated sequentially, so `vars[i].value` may reference any of `vars[0..i-1]`.'), body: opts.Expr, }).meta({ aid: 'Expr_define' }); } @@ -63,8 +78,8 @@ export class DefineExpr extends Expr { return this.body.evaluate(engine, child); } - typeOf(engine: Engine, scope: TypeScope): Type { - const child: TypeScope = new Map(scope); + typeOf(engine: Engine, scope: Locals): Type { + const child: Locals = new Map(scope); for (const v of this.vars) { const t = v.type ?? typeOf(engine, v.value, child); child.set(v.name, t); @@ -72,43 +87,101 @@ export class DefineExpr extends Expr { return typeOf(engine, this.body, child); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { - const child: TypeScope = new Map(scope); + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { + const child: Locals = new Map(scope); for (let i = 0; i < this.vars.length; i++) { const v = this.vars[i]!; + // Each var name must not be a reserved name and must not collide + // with anything already in scope (including earlier vars in this + // same define — those have been added to `child` by now). + p.at(['vars', i, 'name'], () => checkBindingName(v.name, child, p)); + // Walk the value against `child` (not the parent `scope`), so + // `vars[i].value` can read `vars[0..i-1]` by name. This is the + // "later vars can reference earlier" semantic — runtime + // (`evaluate`) and inference (`typeOf`) match. const valueT = p.at(['vars', i, 'value'], () => walkValidate(engine, v.value, child, p, ctx)); - // When a declared type is present, the value's inferred type must be - // assignable to it. - if (v.type && !v.type.compatible(valueT)) { - p.at(['vars', i, 'value'], () => p.warn('define.var.type-mismatch', - `var '${v.name}' value type '${valueT.name}' not compatible with declared '${v.type!.name}'`)); + // When a declared type is present, the value's inferred type + // must be assignable to it — UNLESS the inferred type is a + // universal placeholder (unbound generic alias, `any`, empty + // iface, etc.). In that case the static type is `we don't + // know yet`; the runtime decides via the value's actual + // shape. Erroring here would force the model into impossible + // hoops — e.g. `fns.fetch(...)` returns R; without a + // call-site `generic: {R: text}` binding, R stays unbound + // and any declared type would mismatch. Better to skip the + // static check and let the runtime parse catch real issues. + if (v.type && !valueT.isUniversal() && !v.type.compatible(valueT)) { + // Render the full TypeCode so the LLM sees `or, num>` + // and `num{min:1,max:1000}` instead of just `'or'` and `'num'` — + // the bare class names give it nothing to act on. + const declaredCode = safeTypeCode(v.type); + const valueCode = safeTypeCode(valueT); + // When the inferred type is an alias name (e.g. `R`), point + // the model at the fix: bind the generic explicitly, or + // omit the declared type. The `name === 'alias'` check + // catches AliasType specifically — `name` is the runtime + // class name. See AliasType for details. + const hint = valueT.name === 'alias' + ? ` (hint: '${valueCode}' is an unbound generic — either bind it via \`generic: {${valueCode}: ...}\` on the call site, pass \`output: typ<...>\`, or omit the declared type so the alias flows through)` + : ''; + p.at(['vars', i, 'value'], () => p.error('define.var.type-mismatch', + `var '${v.name}' value type '${valueCode}' not compatible with declared '${declaredCode}'${hint}`)); } child.set(v.name, v.type ?? valueT); } return p.at('body', () => walkValidate(engine, this.body, child, p, ctx)); } - toCode(registry?: Registry, options: CodeOptions = {}): string { + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { const expectsValue = options.expectsValue ?? false; - const lets = this.vars.map((v) => { - const typeAnno = v.type ? `: ${v.type.toCode()}` : ''; - return `const ${v.name}${typeAnno} = ${v.value.toCode(registry, { expectsValue: true })};`; + const valueOpts = { ...options, expectsValue: true }; + const stmtOpts = { ...options, expectsValue: false }; + + // Each `const : = ;` line — the `value` slot + // gets a child path that lines up with the validator + // (`vars[i].value`); same for `type` (`vars[i].type`). The `name` + // is plain text so the validator's `vars[i].name` errors will + // resolve to the bare-text segment via longest-prefix match. + // + // When the assembled `const … = …;` line exceeds the line-width + // target (80 chars on its FIRST rendered line), wrap right after + // the `=`. Multi-line values keep their internal layout — the + // wrap only adds one break + 2-space indent to the value, so a + // wrapped lambda body stays correctly aligned beneath. + const lets = this.vars.map((v, i) => { + const typeAnno = v.type + ? code`: ${v.type.toGinCode(undefined, options, [...path, 'vars', i, 'type'])}` + : ''; + const value = v.value.toGinCode(registry, valueOpts, [...path, 'vars', i, 'value']); + const compact = code`let ${v.name}${typeAnno} = ${value};`; + const firstLine = compact.text.split('\n', 1)[0]!; + if (firstLine.length <= 80) return compact; + return code`let ${v.name}${typeAnno} =\n ${value.indent(' ')};`; }); if (expectsValue) { - const body = this.body.toCode(registry, { expectsValue: true }); - const indented = [...lets.map((l) => ` ${l}`), ` return ${indentCode(body)};`].join('\n'); - return this.commentPrefix(options) + `(() => {\n${indented}\n})()`; + const body = this.body.toGinCode(registry, valueOpts, [...path, 'body']); + const indentedLets = lets.map((l) => code` ${l}`); + const indentedBody = code` return ${body.indent(' ')};`; + const inner = joinLines([...indentedLets, indentedBody]); + return span(code`${this.commentPrefix(options)}(() => {\n${inner}\n})()`, { path, expr: this }); } - // Statement form: const decls followed by body as a statement. + // Statement form. const bodyKind = (this.body as { kind: string }).kind; - const bodyCode = this.body.toCode(registry, { expectsValue: false }); - const bodyStmt = bodyKind === 'if' || bodyKind === 'switch' || bodyKind === 'loop' || bodyKind === 'block' || bodyKind === 'flow' - ? (bodyKind === 'flow' ? `${bodyCode};` : bodyCode) - : `${bodyCode};`; - return this.commentPrefix(options) + [...lets, bodyStmt].join('\n'); + const bodyCode = this.body.toGinCode(registry, stmtOpts, [...path, 'body']); + const bodyStmt = (bodyKind === 'if' || bodyKind === 'switch' || bodyKind === 'loop' || bodyKind === 'block') + ? bodyCode + : code`${bodyCode};`; + return span( + code`${this.commentPrefix(options)}${joinLines([...lets, bodyStmt])}`, + { path, expr: this }, + ); } toJSON(): DefineExprDef { @@ -126,6 +199,40 @@ export class DefineExpr extends Expr { }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const varItems = this.vars.map((v, i) => { + const varPath = [...path, 'vars', i] as const; + const valueCode = v.value.toJSONCode([...varPath, 'value'], indent, level + 3); + const typeCode = v.type ? v.type.toJSONCode([...varPath, 'type'], indent, level + 3) : undefined; + return jsonObject( + [ + { key: 'name', value: jsonString(v.name) }, + { key: 'value', value: valueCode }, + { key: 'type', value: typeCode }, + ], + { path: varPath }, + level + 2, + indent, + ); + }); + const bodyCode = this.body.toJSONCode([...path, 'body'], indent, level + 1); + return jsonObject( + [ + { key: 'kind', value: jsonString('define') }, + { key: 'vars', value: jsonArray(varItems, { path: [...path, 'vars'] }, level + 1, indent) }, + { key: 'body', value: bodyCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): DefineExpr { return new DefineExpr( this.vars.map((v) => ({ name: v.name, type: v.type?.clone(), value: v.value.clone() })), @@ -138,3 +245,10 @@ export class DefineExpr extends Expr { visit(this.body, 'inherit'); } } + +/** Render a Type's `toCode()` for use in error messages. Falls back to + * the bare class name if `toCode()` throws (e.g. on a partially-built + * AliasType during validation walks). */ +function safeTypeCode(t: Type): string { + try { return t.toCode(); } catch { return t.name; } +} diff --git a/packages/gin/src/exprs/flow.ts b/packages/gin/src/exprs/flow.ts index ab45cf09..11f5311b 100644 --- a/packages/gin/src/exprs/flow.ts +++ b/packages/gin/src/exprs/flow.ts @@ -5,13 +5,14 @@ import type { Value } from '../value'; import { BreakSignal, ContinueSignal, ExitSignal, ReturnSignal, ThrowSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; +import { Code, code, span, jsonObject, jsonString } from '../code'; import { z } from 'zod'; -import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export type FlowAction = 'break' | 'return' | 'continue' | 'exit' | 'throw'; @@ -30,21 +31,37 @@ export class FlowExpr extends Expr { super(); } - static from(json: FlowExprDef, registry: Registry): FlowExpr { + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: FlowExprDef, scope: TypeScope): FlowExpr { + const r = scope.registry; return new FlowExpr( json.action, - json.value ? registry.parseExpr(json.value) : undefined, - json.error ? registry.parseExpr(json.error) : undefined, + json.value ? r.parseExpr(json.value, scope) : undefined, + json.error ? r.parseExpr(json.error, scope) : undefined, ).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('flow'), - ...baseExprFields, - action: z.enum(['break', 'continue', 'return', 'exit', 'throw']), - value: opts.Expr.optional(), - error: opts.Expr.optional(), + // No `comment` field — keywords (return/break/continue/throw/exit) + // already say what they do; comments are pure noise. Strict-mode + // schema rejects them. Comments belong on statement-shaped Exprs + // (if/switch/define/block/lambda) only. + action: z.enum(['break', 'continue', 'return', 'exit', 'throw']).describe( + 'Which control-flow signal to raise. ' + + '`break`/`continue` only valid inside a loop. ' + + '`return` only valid inside a fn body / lambda; unwinds to the enclosing call with `value`. ' + + '`exit` unwinds all the way to `engine.run`, returning `value` as the program result. ' + + '`throw` raises `error` (caught by a path step\'s `catch:` handler).', + ), + value: opts.Expr.optional().describe( + 'Required for `return` and `exit` (the value being returned). Ignored by `break` / `continue` / `throw`.', + ), + error: opts.Expr.optional().describe( + 'Required for `throw` — the value to raise. Ignored otherwise.', + ), }).meta({ aid: 'Expr_flow' }); } @@ -68,11 +85,11 @@ export class FlowExpr extends Expr { } } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return engine.registry.void(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { if ((this.action === 'break' || this.action === 'continue') && !ctx.inLoop) { p.error('flow.outside-loop', `${this.action} used outside a loop`); } @@ -93,20 +110,60 @@ export class FlowExpr extends Expr { * ternary or IIFE. Callers that asked for a value-producing form should * treat this as "never returns" semantically. */ - toCode(registry?: Registry, options: CodeOptions = {}): string { + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { const prefix = this.commentPrefix(options); - let code: string; + const valueOpts = { ...options, expectsValue: true }; + let body: Code; switch (this.action) { - case 'break': code = 'break'; break; - case 'continue': code = 'continue'; break; - case 'return': code = this.value ? `return ${this.value.toCode(registry, { expectsValue: true })}` : 'return'; break; - case 'throw': code = this.error ? `throw ${this.error.toCode(registry, { expectsValue: true })}` : 'throw'; break; - case 'exit': code = this.value - ? `/* exit */ return ${this.value.toCode(registry, { expectsValue: true })}` - : '/* exit */ return'; break; - default: code = ''; + case 'break': body = new Code('break'); break; + case 'continue': body = new Code('continue'); break; + case 'return': + body = this.value + ? code`return ${this.value.toGinCode(registry, valueOpts, [...path, 'value'])}` + : new Code('return'); + break; + case 'throw': + body = this.error + ? code`throw ${this.error.toGinCode(registry, valueOpts, [...path, 'error'])}` + : new Code('throw'); + break; + case 'exit': + body = this.value + ? code`exit ${this.value.toGinCode(registry, valueOpts, [...path, 'value'])}` + : new Code('exit'); + break; + default: body = new Code(''); } - return prefix + code; + return span(code`${prefix}${body}`, { path, expr: this }); + } + + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const valueCode = this.value + ? this.value.toJSONCode([...path, 'value'], indent, level + 1) + : undefined; + const errorCode = this.error + ? this.error.toJSONCode([...path, 'error'], indent, level + 1) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('flow') }, + { key: 'action', value: jsonString(this.action) }, + { key: 'value', value: valueCode }, + { key: 'error', value: errorCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); } toJSON(): FlowExprDef { diff --git a/packages/gin/src/exprs/get.ts b/packages/gin/src/exprs/get.ts index 2d5177b6..d790532f 100644 --- a/packages/gin/src/exprs/get.ts +++ b/packages/gin/src/exprs/get.ts @@ -5,12 +5,14 @@ import type { Value } from '../value'; import { Path } from '../path'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; +import { Code, code, span, jsonObject, jsonString } from '../code'; import { z } from 'zod'; -import { baseExprFields, pathStepSchema } from '../schemas'; +import { pathStepSchema } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * GetExpr — read a value through a Path chain. @@ -24,15 +26,18 @@ export class GetExpr extends Expr { super(); } - static from(json: GetExprDef, registry: Registry): GetExpr { - return new GetExpr(Path.from(json.path, registry)).withComment(json.comment); + static from(json: GetExprDef, scope: TypeScope): GetExpr { + return new GetExpr(Path.from(json.path, scope)).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('get'), - ...baseExprFields, - path: z.array(pathStepSchema(opts)), + path: z + .array(pathStepSchema(opts)) + .describe( + 'Steps walked left-to-right starting from a scope variable. Step shapes: `{prop:"name"}` for prop/method access, `{args:{…}}` to call the previous step, `{key:Expr}` for index access. The first step MUST be a prop step (the scope-var name). Result is the final step\'s value.', + ), }).meta({ aid: 'Expr_get' }); } @@ -40,22 +45,45 @@ export class GetExpr extends Expr { return this.path.walk(scope, _engine, { mode: 'get' }); } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { return this.path.typeOf(engine, scope); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { return this.path.validateWalk(engine, scope, p, ctx, 'get'); } - toCode(registry?: Registry, options: CodeOptions = {}): string { - return this.commentPrefix(options) + this.path.toCode(registry!); + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { + const pathCode = this.path.toGinCode(registry!, options, path); + return span(code`${this.commentPrefix(options)}${pathCode}`, { path, expr: this }); } toJSON(): GetExprDef { return this.withCommentOn({ kind: 'get', path: this.path.toJSON() }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const pathCode = this.path.toJSONCode([...path, 'path'], indent, level + 1); + return jsonObject( + [ + { key: 'kind', value: jsonString('get') }, + { key: 'path', value: pathCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): GetExpr { return new GetExpr(this.path.clone()).withComment(this.comment); } diff --git a/packages/gin/src/exprs/if.ts b/packages/gin/src/exprs/if.ts index 5d84cd15..9cb015cc 100644 --- a/packages/gin/src/exprs/if.ts +++ b/packages/gin/src/exprs/if.ts @@ -4,14 +4,16 @@ import type { IfExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; -import { indentCode, renderStatementBody, findEscapingFlow } from './code'; +import { indentCode, renderStatementBody, renderStatementBodyRich, findEscapingFlow } from './code'; +import { Code, code, span, jsonObject, jsonArray, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export interface IfBranch { condition: Expr; @@ -29,12 +31,15 @@ export class IfExpr extends Expr { super(); } - static from(json: IfExprDef, registry: Registry): IfExpr { + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: IfExprDef, scope: TypeScope): IfExpr { + const r = scope.registry; const ifs = json.ifs.map((b) => ({ - condition: registry.parseExpr(b.condition), - body: registry.parseExpr(b.body), + condition: r.parseExpr(b.condition, scope), + body: r.parseExpr(b.body, scope), })); - return new IfExpr(ifs, json.else ? registry.parseExpr(json.else) : undefined) + return new IfExpr(ifs, json.else ? r.parseExpr(json.else, scope) : undefined) .withComment(json.comment); } @@ -42,8 +47,17 @@ export class IfExpr extends Expr { return z.object({ kind: z.literal('if'), ...baseExprFields, - ifs: z.array(z.object({ condition: opts.Expr, body: opts.Expr })), - else: opts.Expr.optional(), + ifs: z + .array(z.object({ + condition: opts.Expr.describe('Bool-typed expression. First branch whose condition is `true` wins; the rest are skipped.'), + body: opts.Expr.describe('Evaluated when this branch\'s condition is true. The if-expression\'s value is this body\'s value.'), + })) + .describe( + 'Ordered list of `{condition, body}` branches — first true condition wins. Each `condition` must be bool-typed (warned otherwise). With multiple branches this is the gin equivalent of `if / else if / else if`.', + ), + else: opts.Expr.optional().describe( + 'Optional fallback evaluated when every `ifs[i].condition` is false. Without an else, a no-match if-expression evaluates to void.', + ), }).meta({ aid: 'Expr_if' }); } @@ -56,13 +70,13 @@ export class IfExpr extends Expr { return val(engine.registry.void(), undefined); } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { const ts = this.ifs.map((b) => typeOf(engine, b.body, scope)); if (this.otherwise) ts.push(typeOf(engine, this.otherwise, scope)); return ts.length === 1 ? ts[0]! : engine.registry.or(ts); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const bool = engine.registry.bool(); const ts: Type[] = []; for (let i = 0; i < this.ifs.length; i++) { @@ -70,8 +84,16 @@ export class IfExpr extends Expr { const condT = p.at(['ifs', i, 'condition'], () => walkValidate(engine, br.condition, scope, p, ctx)); if (!bool.compatible(condT)) { + // Render the full TypeCode (e.g. `fn(x: num): bool` or + // `optional`) so the LLM sees what it's actually + // looking at — naked `'fn'` / `'optional'` is a class name + // with no clue to act on. Common mistake: forgetting `()` + // on a method, leaving the function-value rather than its + // bool result. + let condCode: string; + try { condCode = condT.toCode(); } catch { condCode = condT.name; } p.at(['ifs', i, 'condition'], () => - p.warn('if.condition.type', `if condition should be bool, got '${condT.name}'`)); + p.warn('if.condition.type', `if condition should be bool, got '${condCode}' (did you forget to call a method?)`)); } ts.push(p.at(['ifs', i, 'body'], () => walkValidate(engine, br.body, scope, p, ctx))); } @@ -79,43 +101,59 @@ export class IfExpr extends Expr { return ts.length === 1 ? ts[0]! : engine.registry.or(ts); } - toCode(registry?: Registry, options: CodeOptions = {}): string { + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { const expectsValue = options.expectsValue ?? false; - // Any escaping flow in a branch body forbids ternary/IIFE rendering. const hasFlow = this.ifs.some((b) => !!findEscapingFlow(b.body)) || (this.otherwise ? !!findEscapingFlow(this.otherwise) : false); const prefix = this.commentPrefix(options); + const valueOpts = { ...options, expectsValue: true }; - // Expression context with no non-local flow: ternary or IIFE. if (expectsValue && !hasFlow) { if (this.ifs.length === 1 && this.otherwise) { const b = this.ifs[0]!; - return prefix + `(${b.condition.toCode(registry, { expectsValue: true })} ? ${b.body.toCode(registry, { expectsValue: true })} : ${this.otherwise.toCode(registry, { expectsValue: true })})`; + const cond = b.condition.toGinCode(registry, valueOpts, [...path, 'ifs', 0, 'condition']); + const body = b.body.toGinCode(registry, valueOpts, [...path, 'ifs', 0, 'body']); + const els = this.otherwise.toGinCode(registry, valueOpts, [...path, 'else']); + return span(code`${prefix}(${cond} ? ${body} : ${els})`, { path, expr: this }); } - const branches = this.ifs.map((b, i) => { + let branches = code``; + for (let i = 0; i < this.ifs.length; i++) { + const b = this.ifs[i]!; const kw = i === 0 ? 'if' : 'else if'; - return ` ${kw} (${b.condition.toCode(registry, { expectsValue: true })}) return ${indentCode(b.body.toCode(registry, { expectsValue: true }))};`; - }).join('\n'); - const elseClause = this.otherwise - ? `\n return ${indentCode(this.otherwise.toCode(registry, { expectsValue: true }))};` - : ''; - return prefix + `(() => {\n${branches}${elseClause}\n})()`; + const cond = b.condition.toGinCode(registry, valueOpts, [...path, 'ifs', i, 'condition']); + const body = b.body.toGinCode(registry, valueOpts, [...path, 'ifs', i, 'body']); + const sep = i === 0 ? '' : '\n'; + branches = code`${branches}${sep} ${kw} (${cond}) return ${body.indent(' ')};`; + } + let elseClause: Code | string = ''; + if (this.otherwise) { + const els = this.otherwise.toGinCode(registry, valueOpts, [...path, 'else']); + elseClause = code`\n return ${els.indent(' ')};`; + } + return span(code`${prefix}(() => {\n${branches}${elseClause}\n})()`, { path, expr: this }); } // Statement form. - let out = ''; + let out: Code = code``; for (let i = 0; i < this.ifs.length; i++) { const b = this.ifs[i]!; const kw = i === 0 ? 'if' : 'else if'; const leading = i === 0 ? '' : ' '; - out += `${leading}${kw} (${b.condition.toCode(registry, { expectsValue: true })}) ${renderStatementBody(b.body, registry)}`; + const cond = b.condition.toGinCode(registry, valueOpts, [...path, 'ifs', i, 'condition']); + const body = renderStatementBodyRich(b.body, registry, options, [...path, 'ifs', i, 'body']); + out = code`${out}${leading}${kw} (${cond}) ${body}`; } if (this.otherwise) { - out += ` else ${renderStatementBody(this.otherwise, registry)}`; + const els = renderStatementBodyRich(this.otherwise, registry, options, [...path, 'else']); + out = code`${out} else ${els}`; } - return prefix + out; + return span(code`${prefix}${out}`, { path, expr: this }); } toJSON(): IfExprDef { @@ -126,6 +164,39 @@ export class IfExpr extends Expr { }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const branchItems = this.ifs.map((b, i) => { + const branchPath = [...path, 'ifs', i] as const; + return jsonObject( + [ + { key: 'condition', value: b.condition.toJSONCode([...branchPath, 'condition'], indent, level + 3) }, + { key: 'body', value: b.body.toJSONCode([...branchPath, 'body'], indent, level + 3) }, + ], + { path: branchPath }, + level + 2, + indent, + ); + }); + const elseCode = this.otherwise + ? this.otherwise.toJSONCode([...path, 'else'], indent, level + 1) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('if') }, + { key: 'ifs', value: jsonArray(branchItems, { path: [...path, 'ifs'] }, level + 1, indent) }, + { key: 'else', value: elseCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): IfExpr { return new IfExpr( this.ifs.map((b) => ({ condition: b.condition.clone(), body: b.body.clone() })), diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index d1225160..013fd4a7 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -5,13 +5,15 @@ import { Value } from '../value'; import { ReturnSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; +import { Code, code, span, jsonObject, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import { LocalScope, type TypeScope } from '../type-scope'; /** * LambdaExpr — a callable value that closes over the lexical scope. @@ -32,22 +34,36 @@ export class LambdaExpr extends Expr { super(); } - static from(json: LambdaExprDef, registry: Registry): LambdaExpr { - const constraint = json.constraint ? registry.parseExpr(json.constraint) : undefined; - return new LambdaExpr( - registry.parse(json.type), - registry.parseExpr(json.body), - constraint, - ).withComment(json.comment); + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: LambdaExprDef, scope: TypeScope): LambdaExpr { + const registry = scope.registry; + // Parse the fn type first (FnType.from layers its own LocalScope + // for declared generics). Then build a body scope on top so bare + // alias / generic references inside the body / constraint resolve + // through AliasType. + const fnType = registry.parse(json.type, scope); + const bodyScope = buildBodyScope(scope, fnType); + const body = registry.parseExpr(json.body, bodyScope); + const constraint = json.constraint + ? registry.parseExpr(json.constraint, bodyScope) + : undefined; + return new LambdaExpr(fnType, body, constraint).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('lambda'), ...baseExprFields, - type: opts.Type, - body: opts.Expr, - constraint: opts.Expr.optional(), + type: opts.Type.describe( + 'The lambda\'s function type — `{ name: "fn", call: { args, returns } }` (or a registered named fn type). The `args` obj defines what the body sees under the `args` scope variable; `returns` is what the body must produce.', + ), + body: opts.Expr.describe( + 'The lambda body. At runtime, scope contains the lexical scope at definition site PLUS `args` (the call arguments) and `recurse` (this same lambda, for self-calls). Read params via `[{prop:"args"},{prop:""}]`.', + ), + constraint: opts.Expr.optional().describe( + 'Optional bool-typed precondition evaluated before the body on every call (with `args` in scope). If it returns false, the call throws. Use for input invariants you want enforced regardless of caller.', + ), }).meta({ aid: 'Expr_lambda' }); } @@ -81,13 +97,13 @@ export class LambdaExpr extends Expr { return new Value(fnType, callable); } - typeOf(_engine: Engine, _scope: TypeScope): Type { + typeOf(_engine: Engine, _scope: Locals): Type { return this.fnType; } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const call = this.fnType.call(); - const child: TypeScope = new Map(scope); + const child: Locals = new Map(scope); child.set('args', call?.args ?? engine.registry.any()); const bodyT = p.at('body', () => walkValidate(engine, this.body, child, p, { ...ctx, inLambda: true })); @@ -110,18 +126,49 @@ export class LambdaExpr extends Expr { return this.fnType; } - toCode(registry?: Registry, options: CodeOptions = {}): string { + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { const call = this.fnType.call(); - const argsType = call?.args?.toCode() ?? 'any'; + const valueOpts = { ...options, expectsValue: true }; const prefix = this.commentPrefix(options); - if (!this.constraint) { - return prefix + `(args: ${argsType}) => ${this.body.toCode(registry, { expectsValue: true })}`; + + // Param list — flatten the obj-typed `args` into individual params so + // the signature reads as plain TS (`(x: num, y: text)` instead of + // `(args: obj{x: num, y: text})`). Non-obj arg types fall back to + // `args: T` so unusual shapes still render. + const params = renderLambdaParams(call?.args, options); + const ret = call?.returns + ? `: ${call.returns.toCode(undefined, options)}` + : ''; + const sig = `(${params})${ret}`; + + const bodyCode = this.body.toGinCode(registry, valueOpts, [...path, 'body']); + const bodyText = bodyCode.text; + + let inner: Code; + if (this.constraint) { + // With a constraint: always block-form so the precondition + body + // are on separate lines. + const consCode = this.constraint.toGinCode(registry, valueOpts, [...path, 'constraint']); + const consInline = inlineSingleLine(consCode); + const indentedBody = bodyCode.indent(' '); + inner = code`${sig} => {\n if (!(${consInline})) throw new Error('constraint');\n return ${indentedBody};\n}`; + } else if (bodyText.includes('\n')) { + // Multi-line body — wrap in a block. + const indentedBody = bodyCode.indent(' '); + inner = code`${sig} => {\n ${indentedBody}\n}`; + } else { + // Compact one-liner. + inner = code`${sig} => ${bodyCode}`; } - const c = this.constraint.toCode(registry, { expectsValue: true }); - // Render the constraint as an inline guard so readers see both the - // precondition and the body. - return prefix - + `(args: ${argsType}) => { if (!(${c})) throw new Error('constraint'); return ${this.body.toCode(registry, { expectsValue: true })}; }`; + return span(prefix ? code`${prefix}${inner}` : inner, { path, expr: this }); + } + + toCode(registry?: Registry, options: CodeOptions = {}): string { + return this.toGinCode(registry, options).toString(); } toJSON(): LambdaExprDef { @@ -133,6 +180,30 @@ export class LambdaExpr extends Expr { }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const typeCode = this.fnType.toJSONCode([...path, 'type'], indent, level + 1); + const bodyCode = this.body.toJSONCode([...path, 'body'], indent, level + 1); + const constraintCode = this.constraint + ? this.constraint.toJSONCode([...path, 'constraint'], indent, level + 1) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('lambda') }, + { key: 'type', value: typeCode }, + { key: 'body', value: bodyCode }, + { key: 'constraint', value: constraintCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): LambdaExpr { return new LambdaExpr( this.fnType.clone(), @@ -146,3 +217,54 @@ export class LambdaExpr extends Expr { if (this.constraint) visit(this.constraint, 'lambda'); } } + +/** Render a lambda's param list. When `args` is an obj-typed param bag + * (the common case), each field becomes its own `name: type` entry — + * the rendered signature drops the `args: obj{...}` wrapper for a + * TS-pseudocode-style flat list. Non-obj arg types keep the `args: T` + * fallback so e.g. opaque generic arg types still render. void/any + * arg types render as an empty list. */ +function renderLambdaParams(args: Type | undefined, options: CodeOptions): string { + if (!args) return ''; + if (args.name === 'void' || args.name === 'any') return ''; + const fields = (args as unknown as { fields?: Record }).fields; + if (!fields) return `args: ${args.toCode(undefined, options)}`; + const entries = Object.entries(fields); + if (entries.length === 0) return ''; + const parts = entries.map(([name, prop]) => { + const optional = prop.type.isOptional?.() ?? false; + const t = optional && typeof prop.type.required === 'function' ? prop.type.required() : prop.type; + const docs = prop.docs && options.includeComments !== false ? `/* ${prop.docs} */ ` : ''; + return `${docs}${name}${optional ? '?' : ''}: ${t.toCode(undefined, options)}`; + }); + return parts.join(', '); +} + +/** Squash a Code value to a single line for inline-guard rendering. The + * constraint is always one expression but its rendered form may span + * lines (e.g. a chained get with wrap-form args). For the inline + * `if (!(...))` guard we collapse those line breaks; spans still + * resolve to the parent path. */ +function inlineSingleLine(c: Code): Code { + if (!c.text.includes('\n')) return c; + return new Code(c.text.replace(/\n\s*/g, ' '), c.spans); +} + +/** Build a body scope that exposes the fnType's `call.types` aliases + * by name, so bare `{name: 'X'}` references inside the body / + * constraint resolve via AliasType. + * + * Generics are NOT bound here — their declared types are constraints, + * not active resolutions. Bare `{name: 'R'}` inside the body remains + * an unresolved AliasType placeholder; concrete resolution comes + * from call-site bindings layered into the scope at invocation + * time. (Aliases ARE bound, since `call.types` declarations are + * type-aliases — substitution targets, not parameters.) */ +function buildBodyScope(parent: TypeScope, fnType: Type): TypeScope { + const local = new LocalScope(parent); + const call = fnType.call(); + if (call?.types) { + for (const [name, t] of Object.entries(call.types)) local.bind(name, t); + } + return local; +} diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 8315941d..f98aedbb 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -5,14 +5,16 @@ import { Value, val } from '../value'; import { BreakSignal, ContinueSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; -import { walkValidate } from '../analysis'; +import type { Locals } from '../analysis'; +import { checkBindingName, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode } from './code'; +import { Code, jsonObject, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export interface LoopParallel { concurrent?: Expr; @@ -36,14 +38,17 @@ export class LoopExpr extends Expr { super(); } - static from(json: LoopExprDef, registry: Registry): LoopExpr { + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: LoopExprDef, scope: TypeScope): LoopExpr { + const r = scope.registry; const parallel = json.parallel ? { - concurrent: json.parallel.concurrent ? registry.parseExpr(json.parallel.concurrent) : undefined, - rate: json.parallel.rate ? registry.parseExpr(json.parallel.rate) : undefined, + concurrent: json.parallel.concurrent ? r.parseExpr(json.parallel.concurrent, scope) : undefined, + rate: json.parallel.rate ? r.parseExpr(json.parallel.rate, scope) : undefined, } : undefined; return new LoopExpr( - registry.parseExpr(json.over), - registry.parseExpr(json.body), + r.parseExpr(json.over, scope), + r.parseExpr(json.body, scope), json.key, json.value, parallel, @@ -54,36 +59,164 @@ export class LoopExpr extends Expr { return z.object({ kind: z.literal('loop'), ...baseExprFields, - over: opts.Expr, - body: opts.Expr, - key: z.string().optional(), - value: z.string().optional(), - parallel: z.object({ - concurrent: opts.Expr.optional(), - rate: opts.Expr.optional(), - }).optional(), + over: opts.Expr.describe( + 'The iterable expression. Two evaluation modes: ' + + '(1) iterable types (list, map, etc. — anything whose `get().loop` is defined) iterate once; the expression is evaluated ONCE at the start. ' + + '(2) bool — while-loop semantics: the expression is RE-EVALUATED each iteration; the loop continues while the value is `true` and exits the moment it becomes `false`. ' + + 'Use `flow:break` / `flow:continue` inside the body to control iteration regardless of mode.', + ), + body: opts.Expr.describe( + "Evaluated once per iteration with the current `key` and `value` bound in scope. Use `{kind:'flow', action:'break'}` or `'continue'` for early-exit. The loop expression itself returns void.", + ), + key: z.string().optional().describe( + 'Override the scope-variable name the iteration index/key is bound under (default: `key`). Must NOT be reserved or shadow an outer scope var. Use to disambiguate when looping inside another loop.', + ), + value: z.string().optional().describe( + 'Override the scope-variable name the iteration value is bound under (default: `value`). Same rules as `key`.', + ), + parallel: z + .object({ + concurrent: opts.Expr.optional().describe( + 'Max in-flight iterations as a num (omit / 1 → strictly sequential). Use when iterations are independent I/O — e.g. fetching N URLs concurrently.', + ), + rate: opts.Expr.optional().describe( + 'Minimum interval between iteration starts. Accepts a num (milliseconds) or a duration. Use to rate-limit fan-out (e.g. avoid hammering an API).', + ), + }) + .optional() + .describe( + 'Opt-in parallelism. Both fields are optional and independent: `concurrent` caps fan-out width, `rate` paces start times. Iterations may finish out of order; the body should not assume sequential ordering. ' + + 'Composes with dynamic (bool while-loop) iteration: the body fans out up to `concurrent`, and `over` is re-evaluated against the outer scope each time a task completes — so accumulating side effects of earlier tasks decide whether more tasks spawn.', + ), }).meta({ aid: 'Expr_loop' }); } async evaluate(engine: Engine, scope: Scope): Promise { const over = await this.over.evaluate(engine, scope); const gs = over.type.get(); - if (!gs?.loop) { - throw new Error(`loop: type '${over.type.name}' has no loop defined on its GetSet`); + // A type is iterable iff its GetSet declares EITHER a `loop` + // ExprDef (static — e.g. list/map iterate via the native) OR + // `loopDynamic: true` (dynamic — e.g. bool while-loop). + const iterable = !!(gs?.loop || gs?.loopDynamic); + if (!iterable) { + throw new Error(`loop: type '${over.type.name}' has no loop or loopDynamic defined on its GetSet`); } const keyName = this.keyName ?? 'key'; const valueName = this.valueName ?? 'value'; + // Read parallel options up front — both dynamic and static modes + // honor them. Dynamic mode requires concurrency to be bounded for + // parallel to be meaningful (otherwise every "tick" of `over` + // would race the body's side effects in unbounded ways), so we + // treat unbounded-concurrent + dynamic as sequential. const concurrent = this.parallel?.concurrent ? Number((await this.parallel.concurrent.evaluate(engine, scope)).raw) : undefined; const rateMs = this.parallel?.rate ? Number((await this.parallel.rate.evaluate(engine, scope)).raw) : undefined; - const parallel = concurrent !== undefined || rateMs !== undefined; + // Dynamic mode: re-evaluate `over` against the OUTER scope each + // iteration. Continue while the value's `raw` is truthy. Body + // mutations (via `set` on vars the expression reads) drive the + // exit condition. `key` is the iteration index, `value` is the + // current re-evaluated value. + if (gs.loopDynamic) { + const indexType = engine.registry.num({ whole: true, min: 0 }); + + // Sequential: simple while-loop. + if (!parallel) { + let current: Value = over; + let iteration = 0; + while (current.raw) { + const iter = scope.child({ + [keyName]: val(indexType, iteration), + [valueName]: current, + }); + try { + await this.body.evaluate(engine, iter); + } catch (sig) { + if (sig instanceof BreakSignal) break; + if (!(sig instanceof ContinueSignal)) throw sig; + } + iteration++; + current = await this.over.evaluate(engine, scope); + } + return val(engine.registry.void(), undefined); + } + + // Dynamic + parallel: spawn up to `concurrent` tasks; whenever + // ANY task completes, re-evaluate `over` against the outer + // scope and — if still truthy — spawn another. The re-eval + // happens AFTER each completion (not before each start) so + // body side effects from the previous batch are visible before + // the next decision. Rate-limits delay starts the same way as + // static mode. + const pool: Set> = new Set(); + const maxConcurrent = concurrent ?? Infinity; + let broken = false; + let lastStart = 0; + let iteration = 0; + + const trySpawn = async (): Promise => { + if (broken) return false; + const current = await this.over.evaluate(engine, scope); + if (!current.raw) return false; + if (rateMs && rateMs > 0) { + const now = Date.now(); + const delta = now - lastStart; + if (delta < rateMs) await new Promise((r) => setTimeout(r, rateMs - delta)); + lastStart = Date.now(); + } + const iter = scope.child({ + [keyName]: val(indexType, iteration), + [valueName]: current, + }); + const task = (async () => { + try { + await this.body.evaluate(engine, iter); + } catch (sig) { + if (sig instanceof ContinueSignal) return; + if (sig instanceof BreakSignal) { broken = true; return; } + throw sig; + } + })(); + const wrapped = task.finally(() => pool.delete(wrapped)); + pool.add(wrapped); + iteration++; + return true; + }; + + // Initial fill — bring the pool up to capacity (or until `over` + // becomes falsy). For unbounded-concurrent, spawn ONE task and + // let the drain loop step the rest sequentially. + const initialCap = Number.isFinite(maxConcurrent) ? maxConcurrent : 1; + while (pool.size < initialCap) { + const ok = await trySpawn(); + if (!ok) break; + } + // Drain — every completion re-evaluates and possibly fills the + // freed slot. + while (pool.size > 0) { + await Promise.race(pool); + while (!broken && pool.size < initialCap) { + const ok = await trySpawn(); + if (!ok) break; + } + } + return val(engine.registry.void(), undefined); + } + + // Static (iterable) path — gs.loop is required here. The check + // at the top rules out the (no loop AND no loopDynamic) case, but + // TS can't narrow through the OR so we re-assert. + const loopExpr = gs.loop; + if (!loopExpr) { + throw new Error(`loop: type '${over.type.name}' has no loop ExprDef on its GetSet`); + } + if (!parallel) { const yieldFn = async (keyVal: Value, valueVal: Value): Promise => { const iter = scope.child({ [keyName]: keyVal, [valueName]: valueVal }); @@ -94,7 +227,7 @@ export class LoopExpr extends Expr { throw sig; } }; - await runLoop(gs.loop, scope, engine, over, yieldFn); + await runLoop(loopExpr, scope, engine, over, yieldFn); return val(engine.registry.void(), undefined); } @@ -134,14 +267,18 @@ export class LoopExpr extends Expr { return val(engine.registry.void(), undefined); } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return engine.registry.void(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const overT = p.at('over', () => walkValidate(engine, this.over, scope, p, ctx)); const gs = overT.get(); - if (!gs?.loop) { + // Iterable: type's GetSet defines either a `loop` ExprDef + // (static — iterated once) or `loopDynamic: true` (re-evaluated + // per iteration; bool uses this for while-loop semantics). + const iterable = !!(gs?.loop || gs?.loopDynamic); + if (!iterable) { p.error('loop.not-iterable', `type '${overT.name}' has no loop defined`); } @@ -167,12 +304,26 @@ export class LoopExpr extends Expr { } } - // Bind key/value using the iterable's actual types (not any) so the - // body validates against correct inner types. Fall back to any only - // when the iterable surface was missing (already errored above). + // If the loop overrides keyName / valueName, the user-chosen names + // must follow the same rules as define vars: not reserved, not + // already in scope. The default `key` / `value` names are reserved + // by gin precisely because loops bind them, so we don't check the + // defaults — only explicit overrides. + if (this.keyName !== undefined) { + p.at('key', () => checkBindingName(this.keyName!, scope, p)); + } + if (this.valueName !== undefined) { + p.at('value', () => checkBindingName(this.valueName!, scope, p)); + } + + // Bind key/value from the iterable's GetSet. Both static and + // dynamic modes share the same `gs.key` / `gs.value` types — for + // bool that's `num{whole,min:0}` / `bool`; for list it's + // `num{whole,min:0}` / ``. Fall back to `any` only when + // the iterable surface was missing (already errored above). const keyType = gs?.key ?? engine.registry.any(); const valueType = gs?.value ?? engine.registry.any(); - const child: TypeScope = new Map(scope); + const child: Locals = new Map(scope); child.set(this.keyName ?? 'key', keyType); child.set(this.valueName ?? 'value', valueType); p.at('body', () => walkValidate(engine, this.body, child, p, { ...ctx, inLoop: true })); @@ -181,30 +332,34 @@ export class LoopExpr extends Expr { toCode(registry?: Registry, options: CodeOptions = {}): string { const expectsValue = options.expectsValue ?? false; - const over = this.over.toCode(registry, { expectsValue: true }); + const valueOpts = { ...options, expectsValue: true }; + const stmtOpts = { ...options, expectsValue: false }; + const over = this.over.toCode(registry, valueOpts); const key = this.keyName ?? 'key'; const value = this.valueName ?? 'value'; let prefix = ''; - if (this.parallel?.concurrent) { - prefix += `/* parallel.concurrent: ${this.parallel.concurrent.toCode(registry, { expectsValue: true })} */ `; - } - if (this.parallel?.rate) { - prefix += `/* parallel.rate: ${this.parallel.rate.toCode(registry, { expectsValue: true })} */ `; + if (options.includeComments !== false) { + if (this.parallel?.concurrent) { + prefix += `/* parallel.concurrent: ${this.parallel.concurrent.toCode(registry, valueOpts)} */ `; + } + if (this.parallel?.rate) { + prefix += `/* parallel.rate: ${this.parallel.rate.toCode(registry, valueOpts)} */ `; + } } // Body in statement context — uses bare statements / flow / nested control. const bodyStmt = (() => { const kind = (this.body as { kind: string }).kind; - if (kind === 'flow') return `${this.body.toCode(registry, { expectsValue: false })};`; + if (kind === 'flow') return `${this.body.toCode(registry, stmtOpts)};`; if (kind === 'block') { - const code = this.body.toCode(registry, { expectsValue: false }); + const code = this.body.toCode(registry, stmtOpts); return code.startsWith('{') ? code.slice(1, -1).trim() : code; } if (kind === 'if' || kind === 'switch' || kind === 'loop') { - return this.body.toCode(registry, { expectsValue: false }); + return this.body.toCode(registry, stmtOpts); } - return `${this.body.toCode(registry, { expectsValue: false })};`; + return `${this.body.toCode(registry, stmtOpts)};`; })(); const forStmt = `${prefix}for (const [${key}, ${value}] of ${over}) {\n ${indentCode(bodyStmt)}\n}`; @@ -234,6 +389,40 @@ export class LoopExpr extends Expr { return this.withCommentOn(out); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const overCode = this.over.toJSONCode([...path, 'over'], indent, level + 1); + const bodyCode = this.body.toJSONCode([...path, 'body'], indent, level + 1); + const parallelCode = this.parallel + ? jsonObject( + [ + { key: 'concurrent', value: this.parallel.concurrent?.toJSONCode([...path, 'parallel', 'concurrent'], indent, level + 2) }, + { key: 'rate', value: this.parallel.rate?.toJSONCode([...path, 'parallel', 'rate'], indent, level + 2) }, + ], + { path: [...path, 'parallel'] }, + level + 1, + indent, + ) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('loop') }, + { key: 'over', value: overCode }, + { key: 'body', value: bodyCode }, + { key: 'key', value: this.keyName !== undefined ? jsonString(this.keyName) : undefined }, + { key: 'value', value: this.valueName !== undefined ? jsonString(this.valueName) : undefined }, + { key: 'parallel', value: parallelCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): LoopExpr { return new LoopExpr( this.over.clone(), @@ -262,8 +451,24 @@ async function runLoop( over: Value, yieldFn: (k: Value, v: Value) => Promise, ): Promise { - const yieldType = engine.registry.fn(engine.registry.obj({}), engine.registry.void()); - const yieldValue = new Value(yieldType, yieldFn); + // `yield` in the loop scope is a callable Value with args + // `obj({key, value})` and void return. The Value form is what makes + // it usable from a CUSTOM loop ExprDef (e.g. a `block`/`lambda` + // written by a dev that augments a type with their own iteration + // shape) — path-walker call sites pass a single args-obj Value, so + // yield's signature has to match. Native loop impls receive the + // same Value via `scope.get('yield')` and unwrap the two fields. + const r = engine.registry; + const yieldType = r.fn( + r.obj({ key: { type: r.any() }, value: { type: r.any() } }), + r.void(), + ); + const wrappedYield = async (argsValue: Value): Promise => { + const fields = argsValue.raw as Record | null | undefined; + if (!fields) throw new Error('yield: missing args'); + return yieldFn(fields['key']!, fields['value']!); + }; + const yieldValue = new Value(yieldType, wrappedYield); const loopScope = scope.child({ this: over, yield: yieldValue }); try { await engine.evaluate(loopExpr, loopScope); diff --git a/packages/gin/src/exprs/native.ts b/packages/gin/src/exprs/native.ts index 0d1f403e..246b4bcc 100644 --- a/packages/gin/src/exprs/native.ts +++ b/packages/gin/src/exprs/native.ts @@ -4,12 +4,14 @@ import type { NativeExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; +import { Code, jsonObject, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * NativeExpr — escape hatch calling a registered native impl by id. @@ -22,8 +24,8 @@ export class NativeExpr extends Expr { super(); } - static from(json: NativeExprDef, registry: Registry): NativeExpr { - return new NativeExpr(json.id, json.type ? registry.parse(json.type) : undefined) + static from(json: NativeExprDef, scope: TypeScope): NativeExpr { + return new NativeExpr(json.id, json.type ? scope.registry.parse(json.type, scope) : undefined) .withComment(json.comment); } @@ -31,8 +33,12 @@ export class NativeExpr extends Expr { return z.object({ kind: z.literal('native'), ...baseExprFields, - id: z.string(), - type: opts.Type.optional(), + id: z.string().describe( + 'Identifier of a native impl registered via `registry.setNative(id, fn)` (e.g. `list.push`, `num.add`). The model should NOT generate `native` expressions directly — methods on built-in types are reached via `get` paths, which gin resolves to natives internally.', + ), + type: opts.Type.optional().describe( + 'Optional type to wrap the native\'s raw return value with when the impl returns a non-Value. Defaults to `any` if omitted.', + ), }).meta({ aid: 'Expr_native' }); } @@ -45,11 +51,11 @@ export class NativeExpr extends Expr { return val(type, out); } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return this.type ?? engine.registry.any(); } - validateWalk(engine: Engine, _scope: TypeScope, p: Problems, _ctx: ValidateContext): Type { + validateWalk(engine: Engine, _scope: Locals, p: Problems, _ctx: ValidateContext): Type { if (!engine.registry.getNative(this.id)) { p.warn('native.unknown', `native impl '${this.id}' is not registered`); } @@ -66,6 +72,27 @@ export class NativeExpr extends Expr { return this.withCommentOn(out); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const typeCode = this.type + ? this.type.toJSONCode([...path, 'type'], indent, level + 1) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('native') }, + { key: 'id', value: jsonString(this.id) }, + { key: 'type', value: typeCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): NativeExpr { return new NativeExpr(this.id, this.type?.clone()).withComment(this.comment); } diff --git a/packages/gin/src/exprs/new.ts b/packages/gin/src/exprs/new.ts index e15332bf..242f46c4 100644 --- a/packages/gin/src/exprs/new.ts +++ b/packages/gin/src/exprs/new.ts @@ -4,13 +4,14 @@ import type { NewExprDef, ExprDef } from '../schema'; import { Value, val } from '../value'; import { ObjType } from '../types/obj'; import type { Registry } from '../registry'; -import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import { joinAuto, type Type } from '../type'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; +import { Code, span, jsonObject, jsonString } from '../code'; import { z } from 'zod'; -import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * NewExpr — construct a Value of a type. @@ -37,8 +38,8 @@ export class NewExpr extends Expr { super(); } - static from(json: NewExprDef, registry: Registry): NewExpr { - return new NewExpr(registry.parse(json.type), json.value).withComment(json.comment); + static from(json: NewExprDef, scope: TypeScope): NewExpr { + return new NewExpr(scope.registry.parse(json.type, scope), json.value).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -64,9 +65,12 @@ export class NewExpr extends Expr { const instanceBranches = Array.from(byName.values()).map((t) => z.object({ kind: z.literal('new'), - ...baseExprFields, - type: z.object({ name: z.literal(t.name) }).passthrough(), - value: t.toNewSchema(opts).optional(), + type: z.object({ name: z.literal(t.name) }).passthrough().describe( + `Reference to the registered named type \`${t.name}\` — name-only, the registry resolves it to its full definition.`, + ), + value: t.toNewSchema(opts).optional().describe( + `Initial value for the new \`${t.name}\` instance. Each composite slot accepts an Expr (Get, NewExpr, etc.); per-slot type correctness is enforced at runtime.`, + ), }).meta({ aid: `New_${t.name}` }), ); // Per-built-in-class branches — full TypeDef shape + the class's @@ -78,9 +82,12 @@ export class NewExpr extends Expr { const classBranches = opts.registry.typeClasses().map((cls) => z.object({ kind: z.literal('new'), - ...baseExprFields, - type: cls.toSchema(opts), - value: cls.toNewSchema(opts).optional(), + type: cls.toSchema(opts).describe( + `Full TypeDef for a \`${cls.NAME}\` instance (name + options + per-class fields).`, + ), + value: cls.toNewSchema(opts).optional().describe( + `Initial value matching this \`${cls.NAME}\` instance. Composites accept Expr slots; primitives accept their raw form.`, + ), }).meta({ aid: `New_${cls.NAME}` }), ); const all = [...instanceBranches, ...classBranches]; @@ -91,9 +98,16 @@ export class NewExpr extends Expr { // Default (non-strict): any TypeDef + any value. return z.object({ kind: z.literal('new'), - ...baseExprFields, - type: opts.Type, - value: z.any().optional(), +// no `comment` field — comment-spam on `new`/`get`/`flow` Exprs is + // pure noise (the literal/path/keyword already conveys intent), so + // strict-mode schema rejects them outright. Comments belong on + // statement-shaped Exprs (if/switch/define/block/lambda) only. + type: opts.Type.describe( + 'TypeDef of the value being constructed. The `value` field is interpreted relative to this type — primitives take their raw form (`new num` → number), composites take Expr slots (`new list` → Expr[]).', + ), + value: z.any().optional().describe( + 'Initial value matching `type`. Optional when the type has a defined `init` constructor or a sensible default (empty list, zero num with no constraints, etc.).', + ), }).meta({ aid: 'Expr_new' }); } @@ -119,16 +133,28 @@ export class NewExpr extends Expr { return val(type, type.create()); } - typeOf(_engine: Engine, _scope: TypeScope): Type { + typeOf(_engine: Engine, _scope: Locals): Type { return this.type; } - validateWalk(_engine: Engine, _scope: TypeScope, _p: Problems, _ctx: ValidateContext): Type { + validateWalk(_engine: Engine, _scope: Locals, p: Problems, _ctx: ValidateContext): Type { + // Warn when the value is missing on a structural type with required + // fields — the runtime fills with `type.create()` defaults (zero + // for num, "" for text, null for optional, etc.), which is almost + // never what the author meant. The model frequently writes + // `{kind: 'new', type: obj{a, b}}` (no value) when it intends to + // declare placeholder slots; the resulting obj has zero/empty + // values that silently substitute into templates and arithmetic. + // Catching this at validate time saves a debug round-trip. + if (this.value === undefined && hasRequiredFields(this.type)) { + p.warn('new.value.missing', + `\`new ${this.type.name}\` has no \`value\` — every field will fall to its type default (0 / "" / null). Provide a \`value\` matching the type's shape.`); + } return this.type; } - toCode(_registry?: Registry, options: CodeOptions = {}): string { - const typeName = this.type.toCode(); + toCode(registry?: Registry, options: CodeOptions = {}): string { + const typeName = this.type.toCode(undefined, options); let code: string; if (this.value === undefined) { // An omitted value on an optional type IS `undefined`; otherwise @@ -138,7 +164,7 @@ export class NewExpr extends Expr { else if (typeof this.value === 'number' || typeof this.value === 'boolean') code = String(this.value); else if (typeof this.value === 'string') code = JSON.stringify(this.value); else if (this.value === null) code = 'null'; - else code = `${JSON.stringify(this.value)} as ${typeName}`; + else code = renderNewValue(this.value, registry, this.type, options); return this.commentPrefix(options) + code; } @@ -146,11 +172,120 @@ export class NewExpr extends Expr { return this.withCommentOn({ kind: 'new', type: this.type.toJSON(), value: this.value }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const typeCode = this.type.toJSONCode([...path, 'type'], indent, level + 1); + // The `value` field on the new-Expr is inlined literally (no + // child-Expr toJSONCode to delegate to). Continuation lines need + // to be re-anchored to (level + 1) — the depth of the `value` + // field inside the parent Code — so nested array / obj items + // sit one indent level deeper than `value` itself. + const valueText = this.value === undefined + ? undefined + : (() => { + const text = JSON.stringify(this.value, null, indent); + const reindentLevel = level + 1; + return reindentLevel > 0 + ? text.replace(/\n/g, '\n' + ' '.repeat(reindentLevel * indent)) + : text; + })(); + const valueSpan = valueText !== undefined + ? span(valueText, { path: [...path, 'value'] }) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('new') }, + { key: 'type', value: typeCode }, + { key: 'value', value: valueSpan }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): NewExpr { return new NewExpr(this.type.clone(), this.value).withComment(this.comment); } } +/** + * Render the `value` slot of a `{kind:'new'}` expression as readable + * source instead of a raw `JSON.stringify(...) as TypeName` dump. + * + * The value can hold: + * - primitive (already handled by the caller) + * - ExprDef (object with `kind`) — recurse via the registry's + * `parseExpr` and call its `toCode`. Mirrors how a hand-written + * `new list { value: [, ] }` would read. + * - array — list-shaped `new`; render `[item, item]` with each slot + * recursed. + * - plain object — obj-shaped `new`; render `{ key: value, ... }` + * with each value recursed. + * + * Without a registry we can't parse ExprDefs back into Exprs; in that + * case we still recurse over the array / object structure but render + * primitive leaves directly and bail to JSON.stringify for any + * ExprDef-shaped node we can't decode. + */ +function renderNewValue(value: unknown, registry: Registry | undefined, type: Type | undefined, options: CodeOptions = {}): string { + // ExprDef-shaped → render via the parsed Expr's toCode. + if (registry && value && typeof value === 'object' && !Array.isArray(value) + && 'kind' in (value as Record) + && typeof (value as { kind: unknown }).kind === 'string') { + const kind = (value as { kind: string }).kind; + if (registry.exprClass(kind)) { + try { + return registry.parseExpr(value as ExprDef).toCode(registry, { ...options, expectsValue: true }); + } catch { /* fall through to literal rendering */ } + } + } + + const typeName = type ? type.toCode(undefined, options) : ''; + + if (Array.isArray(value)) { + // For list-shaped types we know each item's type from `list.item`, + // so propagate it down — items render as `new Point {...}` instead + // of `new {...}` when the parent declared `list`. + const itemType = (type as unknown as { item?: Type } | undefined)?.item; + const parts = value.map((v) => renderNewValueLeaf(v, registry, itemType, options)); + const joined = joinAuto(parts); + return joined.startsWith('\n') ? `[${joined}]` : `[${joined}]`; + } + + if (value && typeof value === 'object') { + // For obj-shaped types we know each field's type from `obj.fields`, + // so a nested obj literal can render with its declared field type + // (`{ pos: new Point {x:1,y:2} }` instead of ``). + const fields = (type as unknown as { fields?: Record } | undefined)?.fields; + const entries = Object.entries(value as Record); + if (entries.length === 0) return `new ${typeName}()`; + const parts = entries.map(([k, v]) => `${k}: ${renderNewValueLeaf(v, registry, fields?.[k]?.type, options)}`); + const joined = joinAuto(parts); + return joined.startsWith('\n') + ? `new ${typeName} {${joined}}` + : `new ${typeName} { ${joined} }`; + } + + // Last resort — primitive / null already handled in caller; this + // is for unexpected shapes. + return JSON.stringify(value); +} + +function renderNewValueLeaf(v: unknown, registry: Registry | undefined, type: Type | undefined, options: CodeOptions = {}): string { + if (v === null) return 'null'; + if (v === undefined) return 'undefined'; + if (typeof v === 'string') return JSON.stringify(v); + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + // Recurse with whatever element / field type the parent supplied; + // composite leaves at the deepest level fall back to ``. + return renderNewValue(v, registry, type, options); +} + /** * For an Obj type (including Extensions over Obj via `.base` delegation), * evaluate each field's `default` Expr for any missing input key. Leaves @@ -211,3 +346,23 @@ function asObjType(type: Type): ObjType | undefined { const base = (type as unknown as { base?: Type }).base; return base ? asObjType(base) : undefined; } + +/** + * True when the type is an obj (or extension thereof) with at least + * one required field. Other shapes — list/map/tuple/scalar — either + * default to empty (not interesting) or have no structural fields + * to populate, so a missing `value` isn't suspicious for them. + * + * We deliberately skip the generic `type.props()` path because + * `props()` includes inherited methods on every type (map.set, + * num.add, etc.); treating those as "required fields" would force + * the warning on every typed value with methods. + */ +function hasRequiredFields(type: Type): boolean { + const obj = asObjType(type); + if (!obj) return false; + for (const prop of Object.values(obj.fields)) { + if (!prop.type.isOptional()) return true; + } + return false; +} diff --git a/packages/gin/src/exprs/set.ts b/packages/gin/src/exprs/set.ts index 649c399c..c95759d3 100644 --- a/packages/gin/src/exprs/set.ts +++ b/packages/gin/src/exprs/set.ts @@ -5,12 +5,14 @@ import { Value, val } from '../value'; import { Path, PropStep } from '../path'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; +import { Code, code, span, jsonObject, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields, pathStepSchema } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * SetExpr — assign to a Path. Returns Value: @@ -25,8 +27,10 @@ export class SetExpr extends Expr { super(); } - static from(json: SetExprDef, registry: Registry): SetExpr { - return new SetExpr(Path.from(json.path, registry), registry.parseExpr(json.value)) + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: SetExprDef, scope: TypeScope): SetExpr { + return new SetExpr(Path.from(json.path, scope), scope.registry.parseExpr(json.value, scope)) .withComment(json.comment); } @@ -34,8 +38,14 @@ export class SetExpr extends Expr { return z.object({ kind: z.literal('set'), ...baseExprFields, - path: z.array(pathStepSchema(opts)), - value: opts.Expr, + path: z + .array(pathStepSchema(opts)) + .describe( + 'Steps walked left-to-right to a writable target. Single-step `[{prop:"x"}]` re-assigns scope variable `x`. Multi-step targets need the type to support set on the final step (a prop with a `set` ExprDef, an indexed slot, or a method whose call has `set:`).', + ), + value: opts.Expr.describe( + 'The expression evaluated and assigned to the path target. Its type must be compatible with the target\'s declared type — checked statically as `set.type-mismatch`.', + ), }).meta({ aid: 'Expr_set' }); } @@ -49,11 +59,11 @@ export class SetExpr extends Expr { return this.path.walk(scope, engine, { mode: 'set', setValue: value }); } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return engine.registry.bool(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const valueT = p.at('value', () => this.value.validateWalk(engine, scope, p, ctx)); const targetT = this.path.validateWalk(engine, scope, p, ctx, 'set'); // The rvalue type must be assignable to the target position's type. @@ -64,9 +74,14 @@ export class SetExpr extends Expr { return engine.registry.bool(); } - toCode(registry?: Registry, options: CodeOptions = {}): string { - return this.commentPrefix(options) - + `${this.path.toCode(registry!)} = ${this.value.toCode(registry, { expectsValue: true })}`; + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { + const pathCode = this.path.toGinCode(registry!, options, path); + const value = this.value.toGinCode(registry, { ...options, expectsValue: true }, [...path, 'value']); + return span(code`${this.commentPrefix(options)}${pathCode} = ${value}`, { path, expr: this }); } toJSON(): SetExprDef { @@ -77,6 +92,26 @@ export class SetExpr extends Expr { }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const pathCode = this.path.toJSONCode([...path, 'path'], indent, level + 1); + const valueCode = this.value.toJSONCode([...path, 'value'], indent, level + 1); + return jsonObject( + [ + { key: 'kind', value: jsonString('set') }, + { key: 'path', value: pathCode }, + { key: 'value', value: valueCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): SetExpr { return new SetExpr(this.path.clone(), this.value.clone()).withComment(this.comment); } diff --git a/packages/gin/src/exprs/switch.ts b/packages/gin/src/exprs/switch.ts index dd5a5fc6..2414799b 100644 --- a/packages/gin/src/exprs/switch.ts +++ b/packages/gin/src/exprs/switch.ts @@ -4,15 +4,17 @@ import type { SwitchExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; -import { indentCode, renderStatementBody, findEscapingFlow } from './code'; +import { indentCode, findEscapingFlow } from './code'; import { FlowExpr } from './flow'; +import { Code, code, span, joinLines, jsonObject, jsonArray, jsonString } from '../code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export interface SwitchCase { equals: ReadonlyArray; @@ -34,14 +36,17 @@ export class SwitchExpr extends Expr { super(); } - static from(json: SwitchExprDef, registry: Registry): SwitchExpr { + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + + static from(json: SwitchExprDef, scope: TypeScope): SwitchExpr { + const r = scope.registry; return new SwitchExpr( - registry.parseExpr(json.value), + r.parseExpr(json.value, scope), json.cases.map((c) => ({ - equals: c.equals.map((e) => registry.parseExpr(e)), - body: registry.parseExpr(c.body), + equals: c.equals.map((e) => r.parseExpr(e, scope)), + body: r.parseExpr(c.body, scope), })), - json.else ? registry.parseExpr(json.else) : undefined, + json.else ? r.parseExpr(json.else, scope) : undefined, ).withComment(json.comment); } @@ -49,12 +54,20 @@ export class SwitchExpr extends Expr { return z.object({ kind: z.literal('switch'), ...baseExprFields, - value: opts.Expr, - cases: z.array(z.object({ - equals: z.array(opts.Expr), - body: opts.Expr, - })), - else: opts.Expr.optional(), + value: opts.Expr.describe( + 'Expression whose result is compared against each case\'s `equals` candidates. Evaluated once.', + ), + cases: z + .array(z.object({ + equals: z.array(opts.Expr).describe( + 'Candidate values for this case. The case wins if `value` equals ANY one of them (logical OR). Each candidate\'s type must be compatible with `value`\'s type — checked as `switch.case.type`.', + ), + body: opts.Expr.describe('Evaluated when this case wins. The switch expression\'s value is this body\'s value.'), + })) + .describe('Ordered list of cases — first match wins. Cases are NOT fall-through; only the matching case\'s body runs.'), + else: opts.Expr.optional().describe( + 'Optional fallback evaluated when no case matches. Without an else, a no-match switch evaluates to void.', + ), }).meta({ aid: 'Expr_switch' }); } @@ -70,14 +83,14 @@ export class SwitchExpr extends Expr { return val(engine.registry.void(), undefined); } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { const ts = this.cases.map((c) => typeOf(engine, c.body, scope)); if (this.otherwise) ts.push(typeOf(engine, this.otherwise, scope)); if (ts.length === 0) return engine.registry.void(); return ts.length === 1 ? ts[0]! : engine.registry.or(ts); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const valueT = p.at('value', () => walkValidate(engine, this.value, scope, p, ctx)); const ts: Type[] = []; for (let i = 0; i < this.cases.length; i++) { @@ -100,36 +113,72 @@ export class SwitchExpr extends Expr { return ts.length === 1 ? ts[0]! : engine.registry.or(ts); } - toCode(registry?: Registry, options: CodeOptions = {}): string { + toGinCode( + registry?: Registry, + options: CodeOptions = {}, + path: ReadonlyArray = [], + ): Code { const expectsValue = options.expectsValue ?? false; const hasFlow = this.cases.some((c) => !!findEscapingFlow(c.body)) || (this.otherwise ? !!findEscapingFlow(this.otherwise) : false); - const head = this.value.toCode(registry, { expectsValue: true }); + const valueOpts = { ...options, expectsValue: true }; + const head = this.value.toGinCode(registry, valueOpts, [...path, 'value']); const prefix = this.commentPrefix(options); if (expectsValue && !hasFlow) { - const cases = this.cases.map((c) => { - const labels = c.equals.map((e) => ` case ${e.toCode(registry, { expectsValue: true })}:`).join('\n'); - return `${labels}\n return ${indentCode(c.body.toCode(registry, { expectsValue: true }))};`; - }).join('\n'); - const def = this.otherwise - ? `\n default:\n return ${indentCode(this.otherwise.toCode(registry, { expectsValue: true }))};` - : ''; - return prefix + `(() => {\n switch (${head}) {\n${cases}${def}\n }\n})()`; + const caseBlocks = this.cases.map((c, i) => { + const labels = joinLines(c.equals.map((e, j) => + code` case ${e.toGinCode(registry, valueOpts, [...path, 'cases', i, 'equals', j])}:`, + )); + const body = c.body.toGinCode(registry, valueOpts, [...path, 'cases', i, 'body']); + return code`${labels}\n return ${body.indent(' ')};`; + }); + const cases = joinLines(caseBlocks); + let def: Code | string = ''; + if (this.otherwise) { + const els = this.otherwise.toGinCode(registry, valueOpts, [...path, 'else']); + def = code`\n default:\n return ${els.indent(' ')};`; + } + return span( + code`${prefix}(() => {\n switch (${head}) {\n${cases}${def}\n }\n})()`, + { path, expr: this }, + ); } - const cases = this.cases.map((c) => { - const labels = c.equals.map((e) => ` case ${e.toCode(registry, { expectsValue: true })}:`).join('\n'); - const bodyCode = renderStatementBody(c.body, registry); + // Statement form — bare indented bodies, no brace wrapping. See + // the long comment in the prior impl for the rationale. + const renderBody = (expr: Expr, bodyPath: ReadonlyArray): Code => { + const kind = (expr as { kind: string }).kind; + if (expr instanceof FlowExpr) { + return code`${expr.toGinCode(registry, { ...options, expectsValue: false }, bodyPath)};`; + } + if (kind === 'block' || kind === 'if' || kind === 'switch' || kind === 'loop') { + return expr.toGinCode(registry, { ...options, expectsValue: false }, bodyPath); + } + return code`${expr.toGinCode(registry, { ...options, expectsValue: true }, bodyPath)};`; + }; + + const caseBlocks = this.cases.map((c, i) => { + const labels = joinLines(c.equals.map((e, j) => + code` case ${e.toGinCode(registry, valueOpts, [...path, 'cases', i, 'equals', j])}:`, + )); + const bodyPath = [...path, 'cases', i, 'body'] as const; + const body = renderBody(c.body, bodyPath).indent(' '); const tail = c.body instanceof FlowExpr ? '' : '\n break;'; - return `${labels}\n ${indentCode(bodyCode)}${tail}`; - }).join('\n'); - const def = this.otherwise - ? `\n default:\n ${indentCode(renderStatementBody(this.otherwise, registry))}` - : ''; - return prefix + `switch (${head}) {\n${cases}${def}\n}`; + return code`${labels}\n ${body}${tail}`; + }); + const cases = joinLines(caseBlocks); + let def: Code | string = ''; + if (this.otherwise) { + const elsBody = renderBody(this.otherwise, [...path, 'else']).indent(' '); + def = code`\n default:\n ${elsBody}`; + } + return span( + code`${prefix}switch (${head}) {\n${cases}${def}\n}`, + { path, expr: this }, + ); } toJSON(): SwitchExprDef { @@ -144,6 +193,43 @@ export class SwitchExpr extends Expr { }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const valueCode = this.value.toJSONCode([...path, 'value'], indent, level + 1); + const caseItems = this.cases.map((c, i) => { + const casePath = [...path, 'cases', i] as const; + const equalsItems = c.equals.map((e, j) => + e.toJSONCode([...casePath, 'equals', j], indent, level + 4)); + return jsonObject( + [ + { key: 'equals', value: jsonArray(equalsItems, { path: [...casePath, 'equals'] }, level + 3, indent) }, + { key: 'body', value: c.body.toJSONCode([...casePath, 'body'], indent, level + 3) }, + ], + { path: casePath }, + level + 2, + indent, + ); + }); + const elseCode = this.otherwise + ? this.otherwise.toJSONCode([...path, 'else'], indent, level + 1) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('switch') }, + { key: 'value', value: valueCode }, + { key: 'cases', value: jsonArray(caseItems, { path: [...path, 'cases'] }, level + 1, indent) }, + { key: 'else', value: elseCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): SwitchExpr { return new SwitchExpr( this.value.clone(), diff --git a/packages/gin/src/exprs/template.ts b/packages/gin/src/exprs/template.ts index 30ec07fb..9ee0b38c 100644 --- a/packages/gin/src/exprs/template.ts +++ b/packages/gin/src/exprs/template.ts @@ -4,14 +4,15 @@ import type { TemplateExprDef, NewExprDef, ExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { NewExpr } from './new'; +import { Code, jsonObject, jsonString } from '../code'; import { z } from 'zod'; -import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * TemplateExpr — string interpolation. @@ -20,54 +21,72 @@ export class TemplateExpr extends Expr { static readonly KIND = 'template'; readonly kind = TemplateExpr.KIND; - constructor(readonly template: Expr, readonly params: Expr) { + constructor(readonly template: Expr, readonly params?: Expr) { super(); } - static from(json: TemplateExprDef, registry: Registry): TemplateExpr { + static from(json: TemplateExprDef, scope: TypeScope): TemplateExpr { + const r = scope.registry; // template is declared `string` in schema but historically evaluated as ExprDef. const t = json.template as unknown; const templateExpr = t && typeof t === 'object' && 'kind' in (t as ExprDef) - ? registry.parseExpr(t as ExprDef) - : registry.parseExpr({ + ? r.parseExpr(t as ExprDef, scope) + : r.parseExpr({ kind: 'new', type: { name: 'text' }, value: String(t), - } as NewExprDef); - return new TemplateExpr(templateExpr, registry.parseExpr(json.params)) - .withComment(json.comment); + } as NewExprDef, scope); + const paramsExpr = json.params ? r.parseExpr(json.params, scope) : undefined; + return new TemplateExpr(templateExpr, paramsExpr).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('template'), - ...baseExprFields, - template: z.union([opts.Expr, z.string()]), - params: opts.Expr, + template: z.union([opts.Expr, z.string()]).describe( + 'The template string — either a literal string (auto-wrapped as `new text`) or an Expr that evaluates to text. Placeholders use `{name}` syntax; each `name` is looked up first in `params` (when supplied) and then in the surrounding scope.', + ), + params: opts.Expr.optional().describe( + 'Optional obj-typed expression supplying placeholder values. When omitted or when a key is missing, `{name}` falls back to a scope lookup of the same name — so a `${baseUrl}` placeholder resolves to a surrounding `define baseUrl = ...`. Provide `params` only when placeholders need values not already in scope (or to override scope values).', + ), }).meta({ aid: 'Expr_template' }); } async evaluate(engine: Engine, scope: Scope): Promise { const tmplValue = await this.template.evaluate(engine, scope); - const paramsValue = await this.params.evaluate(engine, scope); const tmpl = String(tmplValue.raw); - const params = paramsValue.raw as Record; + + // Evaluate `params` (if any) once up front — its fields take + // precedence over scope lookups. Missing keys (or no params at + // all) fall back to `scope.get(name)` so `${foo}` references the + // same `foo` a sibling `get` would. + let params: Record | undefined; + if (this.params) { + const paramsValue = await this.params.evaluate(engine, scope); + params = paramsValue.raw as Record; + } const out = tmpl.replace(/\{(\w+)\}/g, (_, name) => { - const field = params?.[name]; - if (field === undefined || field === null) return ''; - if (field instanceof Value) return String((field as Value).raw ?? ''); - return String(field); + if (params && Object.prototype.hasOwnProperty.call(params, name)) { + const field = params[name]; + if (field === undefined || field === null) return ''; + if (field instanceof Value) return String(field.raw ?? ''); + return String(field); + } + const fromScope = scope.get(name); + if (fromScope === undefined) return ''; + if (fromScope instanceof Value) return String(fromScope.raw ?? ''); + return String(fromScope); }); return val(engine.registry.text(), out); } - typeOf(_engine: Engine, _scope: TypeScope): Type { + typeOf(_engine: Engine, _scope: Locals): Type { return _engine.registry.text(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const text = engine.registry.text(); const tmplT = p.at('template', () => walkValidate(engine, this.template, scope, p, ctx)); if (!text.compatible(tmplT)) { @@ -75,57 +94,198 @@ export class TemplateExpr extends Expr { `template should resolve to text, got '${tmplT.name}'`)); } - const paramsT = p.at('params', () => walkValidate(engine, this.params, scope, p, ctx)); - // params must be an object-shaped type so that `{name}` placeholders - // can be looked up. - if (paramsT.name !== 'object' && paramsT.name !== 'any') { - p.at('params', () => p.warn('template.params.type', - `template params should be an object, got '${paramsT.name}'`)); + // Resolve the params type (when supplied) so we can use its + // structural prop list to decide which placeholders it actually + // exposes — not just the ones inlined in a `new obj` literal. + // `args.config` (a `get` of a typed obj) participates here too. + let paramsKeys: Set | undefined; + let paramsTypeUnknown = false; + if (this.params) { + const paramsT = p.at('params', () => walkValidate(engine, this.params!, scope, p, ctx)); + if (paramsT.name === 'any') { + // `any` could carry any keys at runtime — defer the check. + paramsTypeUnknown = true; + } else if (paramsT.name === 'obj' || paramsT.name === 'iface') { + // Use the structural fields' names. Methods declared on the + // type via `props()` would muddy the check — `obj.fields` + // is the data-slot list. For obj this is `(t as ObjType). + // fields`; iface uses `_props`. Both are exposed via + // `props()` filtered to non-callable types. + const allProps = paramsT.props(); + const dataKeys = Object.entries(allProps) + .filter(([, prop]) => !(prop as { type: Type }).type.call()) + .map(([k]) => k); + paramsKeys = new Set(dataKeys); + } else { + // Not obj/iface/any — params can't supply placeholders even + // if the type happens to have method-typed props. Flag and + // skip the per-name check (already an error). + p.at('params', () => p.warn('template.params.type', + `template params should be an object, got '${paramsT.name}'`)); + paramsTypeUnknown = true; + } + } + + // Walk the template literal for placeholder names and ERROR on + // any that won't resolve at runtime — neither in the params + // type's props (when params is supplied with a knowable shape) + // nor in the surrounding scope. Unresolved placeholders silently + // become empty strings at runtime, which is almost always a bug + // (the user's exchange showed the model debugging + // `Failed to parse URL from ?access_key=` because both + // placeholders quietly resolved to ''), so we promote it from + // warn → error to force a fix before `test()`. + const literalTpl = this.template instanceof NewExpr && typeof this.template.value === 'string' + ? this.template.value as string + : undefined; + if (literalTpl !== undefined) { + const seen = new Set(); + const re = /\{(\w+)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(literalTpl)) !== null) { + const name = m[1]!; + if (seen.has(name)) continue; + seen.add(name); + const inParams = paramsKeys?.has(name) ?? false; + const inScope = scope.has(name); + // When the params type is an opaque `any` (or the params + // type isn't object-shaped — already flagged above), we + // can't statically check key membership; fall back to scope + // alone. Otherwise the placeholder must be in EITHER the + // params keys OR scope. + if (paramsTypeUnknown) { + if (!inScope) { + p.error('template.placeholder.unresolved', + `placeholder '{${name}}' isn't a scope variable; either define '${name}' in scope or pass a typed obj as \`params\` so it can be checked`); + } + } else if (!inParams && !inScope) { + p.error('template.placeholder.unresolved', + this.params + ? `placeholder '{${name}}' is not a key of \`params\` (keys: [${[...(paramsKeys ?? [])].join(', ') || 'none'}]) and not a scope variable` + : `placeholder '{${name}}' does not match any scope variable; either \`define ${name} = ...\` first or pass it via \`params\``); + } + } } return engine.registry.text(); } toCode(registry?: Registry, options: CodeOptions = {}): string { + const prefix = this.commentPrefix(options); const raw = this.template instanceof NewExpr && typeof this.template.value === 'string' ? (this.template.value as string) : undefined; - const prefix = this.commentPrefix(options); + if (raw === undefined) { - return prefix + `template(${registry!.toCode(this.template)}, ${registry!.toCode(this.params)})`; + // Non-literal template — fall back to the explicit form. The + // params slot is rendered (or omitted) so the reader sees the + // full picture. + const tplCode = registry!.toCode(this.template, { ...options, expectsValue: true }); + if (this.params) { + const paramsCode = registry!.toCode(this.params, { ...options, expectsValue: true }); + return `${prefix}template(${tplCode}, ${paramsCode})`; + } + return `${prefix}template(${tplCode})`; } - const inline = tryInlineTemplateParams(this.params, registry!); - const converted = raw.replace(/\{(\w+)\}/g, (_, name) => - inline && name in inline ? '${' + inline[name]! + '}' : '${params.' + name + '}' - ); - return prefix + `\`${converted.replace(/`/g, '\\`')}\``; + + // Literal template string. For each `{name}` placeholder: + // - If `params` is a `new obj` literal AND has a key matching + // the placeholder, render that field's Expr inline as + // `${}`. Long renders (>64 chars) get a multi- + // line `${\n \n}` form so the template doesn't sprawl + // across the page. + // - Otherwise emit a bare `${name}` — at runtime that resolves + // via the surrounding scope (the standard fallback behaviour + // in `evaluate`). + // + // No `with()` clause is emitted: every literal-inlinable + // value is already substituted directly into the template string, + // and bare names speak for themselves. When `params` ISN'T a + // literal (e.g. `args.config` — an obj fetched from elsewhere), + // we can't inline its field exprs at toCode time; the bare + // `${name}` form still reads naturally and the runtime falls + // through to scope. + const inline = this.params ? tryInlineTemplateParams(this.params, registry!) : undefined; + const WRAP_THRESHOLD = 64; + const converted = raw.replace(/\{(\w+)\}/g, (_, name) => { + if (inline && Object.prototype.hasOwnProperty.call(inline, name)) { + const code = inline[name]!; + return code.length > WRAP_THRESHOLD + ? '${\n ' + code + '\n}' + : '${' + code + '}'; + } + return '${' + name + '}'; + }); + + const literal = `\`${converted.replace(/`/g, '\\`')}\``; + return `${prefix}${literal}`; } toJSON(): TemplateExprDef { return this.withCommentOn({ kind: 'template', template: this.template.toJSON() as unknown as string, - params: this.params.toJSON(), + ...(this.params ? { params: this.params.toJSON() } : {}), }); } + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + // The `template` field on the JSON shape is historically allowed + // to be either a literal string or an ExprDef. Render whichever + // the underlying child Expr's `toJSONCode` produces so the span + // path `template` resolves correctly. + const tmplCode = this.template.toJSONCode([...path, 'template'], indent, level + 1); + const paramsCode = this.params + ? this.params.toJSONCode([...path, 'params'], indent, level + 1) + : undefined; + return jsonObject( + [ + { key: 'kind', value: jsonString('template') }, + { key: 'template', value: tmplCode }, + { key: 'params', value: paramsCode }, + ...(this.comment ? [{ key: 'comment', value: jsonString(this.comment) }] : []), + ], + { path, expr: this }, + level, + indent, + ); + } + clone(): TemplateExpr { - return new TemplateExpr(this.template.clone(), this.params.clone()).withComment(this.comment); + return new TemplateExpr(this.template.clone(), this.params?.clone()).withComment(this.comment); } forEachChild(visit: ChildVisitor): void { visit(this.template, 'inherit'); - visit(this.params, 'inherit'); + if (this.params) visit(this.params, 'inherit'); } } -function tryInlineTemplateParams(params: Expr, _registry: Registry): Record | undefined { +function tryInlineTemplateParams(params: Expr, registry: Registry): Record | undefined { if (!(params instanceof NewExpr)) return undefined; const value = params.value; if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { if (typeof v === 'string') out[k] = JSON.stringify(v); - else out[k] = String(v); + else if (typeof v === 'number' || typeof v === 'boolean') out[k] = String(v); + else if (v === null) out[k] = 'null'; + else if (v && typeof v === 'object' && 'kind' in (v as Record) + && typeof (v as { kind: unknown }).kind === 'string' + && registry.exprClass((v as { kind: string }).kind)) { + // ExprDef-shaped: render via the parsed Expr's toCode so a `get` + // path appears as `args.text` instead of `[object Object]`. + try { + out[k] = registry.parseExpr(v as ExprDef).toCode(registry, { expectsValue: true }); + } catch { return undefined; } + } else { + // Unknown shape — bail out of inlining so the caller falls back + // to the safe `template(, )` form. + return undefined; + } } return out; } diff --git a/packages/gin/src/extension.ts b/packages/gin/src/extension.ts index 4ef9ecf1..3ef345a3 100644 --- a/packages/gin/src/extension.ts +++ b/packages/gin/src/extension.ts @@ -1,4 +1,5 @@ import type { Registry } from './registry'; +import type { TypeScope } from './type-scope'; import type { TypeDef } from './schema'; import { Value, val } from './value'; import { @@ -11,17 +12,12 @@ import { type Rnd, Type, } from './type'; -import { - encodeCall, - encodeGetSet, - encodeInit, - encodeProps, -} from './spec'; +import { encodeProps } from './spec'; import type { Scope } from './scope'; import type { Engine } from './engine'; import type { JSONOf, RuntimeOf } from './json-type'; import { z } from 'zod'; -import type { SchemaOptions } from './node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from './node'; import type { Expr } from './expr'; /** @@ -32,13 +28,14 @@ import type { Expr } from './expr'; * any generics the base already has). Each key is the parameter name; the * value is the current binding (use `registry.any()` as a default, or a * concrete Type for a bound instance). Placeholders elsewhere in the - * local spec use `registry.generic('T')` — those get substituted by - * `.bind({T: …})` via the standard substitute walk. + * local spec use `registry.alias('T')` — those resolve through any + * extra `TypeScope` passed at access time (e.g. a path call site's + * `` bindings) before falling back to the captured layer. * - * registry.extend('object', { + * registry.extend('obj', { * name: 'Box', * generic: { T: registry.any() }, - * props: { value: { type: registry.generic('T') } }, + * props: { value: { type: registry.alias('T') } }, * }) */ export interface ExtensionLocal { @@ -123,9 +120,10 @@ export class Extension extends Type { : original; // Thread local generic declarations up to the base Type so `this.generic` - // reflects the Extension's own parameters. Binding via `.bind(bindings)` - // walks through substituteChildren, which rebuilds the Extension with - // substituted placeholders. + // reflects the Extension's own parameters. Generic specialization at + // call sites is handled by passing an extra TypeScope into the + // resolution-touching methods (parse / valid / call / props / etc.) — + // AliasType reads the override layer first, then its captured scope. super(registry, narrowedOptions, local.generic ?? {}); this.original = original; this.base = effectiveBase; @@ -136,18 +134,18 @@ export class Extension extends Type { // ─── VALUE OPERATIONS (delegate to effective base) ───────────────────── - valid(raw: unknown): raw is RuntimeOf { - return this.base.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return this.base.valid(raw, scope); } - parse(json: unknown): Value { - const v = this.base.parse(json); + parse(json: unknown, scope?: TypeScope): Value { + const v = this.base.parse(json, scope); // Re-wrap so Value.type is this Extension, not the base. return new Value(this, v.raw); } - encode(raw: RuntimeOf): JSONOf { - return this.base.encode(raw); + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { + return this.base.encode(raw, scope); } create(): RuntimeOf { @@ -160,20 +158,20 @@ export class Extension extends Type { // ─── TYPE RELATIONS ──────────────────────────────────────────────────── - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (opts?.exact) { // Exact requires same Extension name. if (other instanceof Extension && other.name === this.name) { - return this.base.compatible(other.base, opts); + return this.base.compatible(other.base, opts, scope); } return false; } // Covariant: compatible with base (looser) and with other Extensions // sharing a compatible base. if (other instanceof Extension) { - return this.base.compatible(other.base, opts); + return this.base.compatible(other.base, opts, scope); } - return this.base.compatible(other, opts); + return this.base.compatible(other, opts, scope); } // ─── ALGEBRA ─────────────────────────────────────────────────────────── @@ -217,20 +215,34 @@ export class Extension extends Type { // ─── EFFECTIVE ACCESS SPECS (merge local over base) ──────────────────── - props(): Record { - return { ...this.base.props(), ...(this.local.props ?? {}) }; + props(scope?: TypeScope): Record { + // Order: base (carries `augmentation('obj')`) → registry-augmentation + // for THIS name → extension's own local. Extension-local wins last + // so authors can shadow either base or augmentation on conflict. + const ownAug = this.registry.augmentation(this.name); + return { + ...this.base.props(scope), + ...(ownAug?.props ?? {}), + ...(this.local.props ?? {}), + }; } - get(): GetSet | undefined { - return this.local.get ?? this.base.get(); + get(scope?: TypeScope): GetSet | undefined { + return this.local.get + ?? this.registry.augmentation(this.name)?.get + ?? this.base.get(scope); } - call(): Call | undefined { - return this.local.call ?? this.base.call(); + call(scope?: TypeScope): Call | undefined { + return this.local.call + ?? this.registry.augmentation(this.name)?.call + ?? this.base.call(scope); } - init(): Init | undefined { - return this.local.init ?? this.base.init(); + init(scope?: TypeScope): Init | undefined { + return this.local.init + ?? this.registry.augmentation(this.name)?.init + ?? this.base.init(scope); } // ─── SCHEMA ROUND-TRIP ───────────────────────────────────────────────── @@ -262,9 +274,9 @@ export class Extension extends Type { generic: Object.keys(mergedGeneric).length > 0 ? mergedGeneric : undefined, options: mergedOptions && Object.keys(mergedOptions).length > 0 ? mergedOptions : undefined, props: this.local.props ? encodeProps(this.local.props) : undefined, - get: this.local.get ? encodeGetSet(this.local.get) : undefined, - call: this.local.call ? encodeCall(this.local.call) : undefined, - init: this.local.init ? encodeInit(this.local.init) : undefined, + get: this.local.get?.toJSON(), + call: this.local.call?.toJSON(), + init: this.local.init?.toJSON(), constraint: this.local.constraint ? this.local.constraint.toJSON() : undefined, }; } @@ -288,13 +300,13 @@ export class Extension extends Type { }); } - toCode(): string { return this.docsPrefix() + this.name; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + this.name; } /** Renders `type Email extends text{pattern="..."}` headers in * `toCodeDefinition`. Uses `base` (narrowed) rather than `original` * so the constraints the Extension sits atop are visible. */ - protected extendsClause(): string { - return ` extends ${this.base.toCode()}`; + protected extendsClause(options?: CodeOptions): string { + return ` extends ${this.base.toCode(undefined, options)}`; } // Definition hooks — an Extension's rendered body shows only the @@ -317,7 +329,7 @@ export class Extension extends Type { return this.local.constraint ? [this.local.constraint, ...base] : base; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Extensions normally delegate to base — but when `local.props` adds // data fields atop an object-shaped base (obj/iface), those fields need // to land in the value schema too. Nothing else in the pipeline pushes @@ -341,7 +353,7 @@ export class Extension extends Type { private mergeLocalPropsInto( schema: z.ZodTypeAny, - opts: SchemaOptions | undefined, + opts: ValueSchemaOptions | undefined, slotFor: (prop: Prop) => z.ZodTypeAny, ): z.ZodTypeAny { const props = this.local.props; diff --git a/packages/gin/src/index.ts b/packages/gin/src/index.ts index 1af6488a..29d9737d 100644 --- a/packages/gin/src/index.ts +++ b/packages/gin/src/index.ts @@ -5,6 +5,24 @@ export { buildSchemas } from './schemas'; // Core export * from './problem'; +export { + Code, + code, + span, + plain, + joinCode, + joinLines, + jsonObject, + jsonArray, + jsonString, + formatProblem, + formatProblems, + type Span, + type CodeLine, + type FormatOptions, + type FormatProblemsOptions, + type JSONEntry, +} from './code'; export * from './value'; export * from './scope'; export * from './type'; diff --git a/packages/gin/src/natives/helpers.ts b/packages/gin/src/natives/helpers.ts index a5e67b85..65de30b8 100644 --- a/packages/gin/src/natives/helpers.ts +++ b/packages/gin/src/natives/helpers.ts @@ -1,5 +1,6 @@ import type { Scope } from '../scope'; -import type { Value } from '../value'; +import { Value } from '../value'; +import type { Registry } from '../registry'; /** Extract `this` (the receiver) from a native's scope. */ export function self(scope: Scope): T { @@ -39,3 +40,32 @@ export function epsilon(scope: Scope): number { function isValue(x: unknown): boolean { return !!x && typeof x === 'object' && 'type' in (x as object) && 'raw' in (x as object); } + +/** + * Build a per-iteration `yield(key, value)` callable for a loop native. + * + * The `yield` Value in scope is path-shaped — it takes a single + * args-obj `{key, value}` Value, so it works for native loops AND + * for custom loop ExprDefs a dev writes against an augmented type + * (see `runLoop` in `exprs/loop.ts`). This helper closes over the + * args Type ONCE so the inner per-iteration call is a thin wrapper + * that just packs the two values — no `reg.obj(...)` allocation per + * iteration. + * + * Pass the concrete key / value Types so consumers downstream see + * accurate type metadata, not `any` placeholders. + */ +export function setupYield( + scope: Scope, + registry: Registry, + keyType: { name: string }, + valueType: { name: string }, +): (key: Value, value: Value) => Promise { + const yieldFn = scope.get('yield')!.raw as (args: Value) => Promise; + const argsType = registry.obj({ + key: { type: keyType as any }, + value: { type: valueType as any }, + }); + return (key: Value, value: Value) => + yieldFn(new Value(argsType as any, { key, value } as any)); +} diff --git a/packages/gin/src/natives/list.ts b/packages/gin/src/natives/list.ts index 16868176..8b937336 100644 --- a/packages/gin/src/natives/list.ts +++ b/packages/gin/src/natives/list.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { ListType } from '../types/list'; -import { arg, self, selfValue, argValue } from './helpers'; +import { arg, self, selfValue, argValue, setupYield } from './helpers'; const itemType = (scope: any) => (selfValue(scope).type as ListType).item; @@ -31,11 +31,13 @@ export const listNatives: Record = { }, 'list.iterate': async (scope, reg) => { const arr = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + const doYield = setupYield(scope, reg, indexType, itemType(scope)); + const voidValue = val(reg.void(), undefined); for (let i = 0; i < arr.length; i++) { - await yieldFn(val(reg.num({ whole: true, min: 0 }), i), arr[i]!); + await doYield(val(indexType, i), arr[i]!); } - return val(reg.void(), undefined); + return voidValue; }, 'list.at': (scope, reg) => { diff --git a/packages/gin/src/natives/map.ts b/packages/gin/src/natives/map.ts index fac6550f..851d7391 100644 --- a/packages/gin/src/natives/map.ts +++ b/packages/gin/src/natives/map.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { MapType } from '../types/map'; -import { arg, self, selfValue, argValue } from './helpers'; +import { arg, self, selfValue, argValue, setupYield } from './helpers'; type Entry = [Value, Value]; type MapRaw = Map; @@ -32,9 +32,16 @@ export const mapNatives: Record = { }, 'map.iterate': async (scope, reg) => { const m = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + // The map's K / V types are stored on `selfValue.type.generic`; + // peek at the first stored entry as a quick proxy. If the map + // is empty the loop body never runs so the args type doesn't + // matter — fall back to `any` only in that edge case. + const first = m.values().next().value as [Value, Value] | undefined; + const keyType = first ? first[0].type : reg.any(); + const valueType = first ? first[1].type : reg.any(); + const doYield = setupYield(scope, reg, keyType, valueType); for (const [, [kV, vV]] of m) { - await yieldFn(kV, vV); + await doYield(kV, vV); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/natives/num.ts b/packages/gin/src/natives/num.ts index 42b43bad..1ad4cc6f 100644 --- a/packages/gin/src/natives/num.ts +++ b/packages/gin/src/natives/num.ts @@ -1,6 +1,6 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; -import { arg, epsilon, self } from './helpers'; +import { arg, epsilon, self, setupYield } from './helpers'; export const numNatives: Record = { // comparison (value-approx via epsilon) @@ -54,14 +54,15 @@ export const numNatives: Record = { // loop: yields (key=0..|n|-1, value=0-toward-n) 'num.loop': async (scope, reg) => { const n = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const numType = reg.num(); + const doYield = setupYield(scope, reg, numType, numType); const count = Math.abs(Math.trunc(n)); const step = n < 0 ? -1 : 1; for (let i = 0; i < count; i++) { const v = i * step; // Normalize negative zero to positive zero for consistent equality. const safe = Object.is(v, -0) ? 0 : v; - await yieldFn(val(reg.num(), i), val(reg.num(), safe)); + await doYield(val(numType, i), val(numType, safe)); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/natives/obj.ts b/packages/gin/src/natives/obj.ts index 031cb416..a2eea778 100644 --- a/packages/gin/src/natives/obj.ts +++ b/packages/gin/src/natives/obj.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { ObjType } from '../types/obj'; -import { arg, self, selfValue } from './helpers'; +import { arg, self, selfValue, setupYield } from './helpers'; const fields = (scope: any) => (selfValue(scope).type as ObjType).fields; @@ -29,11 +29,19 @@ export const objNatives: Record = { 'object.iterate': async (scope, reg) => { const obj = self>(scope); const fs = fields(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + // Build the per-iteration yielder once with stable types: keys + // are always text (field names), values are the union of the + // obj's field types (or just any when there are no fields). + const keyType = reg.text(); + const fieldTypes = Object.values(fs).map((p: any) => p.type); + const valueType = fieldTypes.length === 0 + ? reg.any() + : fieldTypes.length === 1 ? fieldTypes[0]! : reg.or(fieldTypes); + const doYield = setupYield(scope, reg, keyType, valueType); for (const [name] of Object.entries(fs)) { const stored = obj[name]; if (stored instanceof Value) { - await yieldFn(val(reg.text(), name), stored); + await doYield(val(keyType, name), stored); } } return val(reg.void(), undefined); diff --git a/packages/gin/src/natives/text.ts b/packages/gin/src/natives/text.ts index 4e004d4b..3ebaa72a 100644 --- a/packages/gin/src/natives/text.ts +++ b/packages/gin/src/natives/text.ts @@ -1,6 +1,6 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; -import { arg, self } from './helpers'; +import { arg, self, setupYield } from './helpers'; export const textNatives: Record = { // field @@ -73,9 +73,11 @@ export const textNatives: Record = { }, 'text.chars': async (scope, reg) => { const s = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + const charType = reg.text({ minLength: 1, maxLength: 1 }); + const doYield = setupYield(scope, reg, indexType, charType); for (let i = 0; i < s.length; i++) { - await yieldFn(val(reg.num({ whole: true, min: 0 }), i), val(reg.text({ minLength: 1, maxLength: 1 }), s[i]!)); + await doYield(val(indexType, i), val(charType, s[i]!)); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/natives/tuple.ts b/packages/gin/src/natives/tuple.ts index 45a3dd01..72e498df 100644 --- a/packages/gin/src/natives/tuple.ts +++ b/packages/gin/src/natives/tuple.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { TupleType } from '../types/tuple'; -import { self, selfValue } from './helpers'; +import { self, selfValue, setupYield } from './helpers'; const elems = (scope: any) => (selfValue(scope).type as TupleType).elements; @@ -26,9 +26,17 @@ export const tupleNatives: Record = { }, 'tuple.iterate': async (scope, reg) => { const arr = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + // Tuple's value-side type is the union of element types — use it + // so the yielded value's args carry an honest static type. (Empty + // tuple short-circuits with `any`, but the loop body never runs.) + const elemTypes = arr.map((v) => v.type); + const valueType = elemTypes.length === 0 + ? reg.any() + : elemTypes.length === 1 ? elemTypes[0]! : reg.or(elemTypes); + const doYield = setupYield(scope, reg, indexType, valueType); for (let i = 0; i < arr.length; i++) { - await yieldFn(val(reg.num({ whole: true, min: 0 }), i), arr[i]!); + await doYield(val(indexType, i), arr[i]!); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/node.ts b/packages/gin/src/node.ts index a9dee40d..795e8197 100644 --- a/packages/gin/src/node.ts +++ b/packages/gin/src/node.ts @@ -4,6 +4,7 @@ import type { Problems } from './problem'; import type { z } from 'zod'; import type { Expr } from './expr'; import type { Type } from './type'; +import type { Code } from './code'; /** * Passed into each class's static `toSchema(opts)` so sub-fields that @@ -21,18 +22,21 @@ import type { Type } from './type'; * - `newStrict` — when true, NewExpr.toSchema emits a discriminated * union over `opts.types` instead of its generic shape. */ -export interface SchemaOptions { - Type: z.ZodTypeAny; - Expr: z.ZodTypeAny; - types: Type[]; - exprs: Expr[]; - /** - * Registry reference so schema builders can enumerate classes and - * registered named types (e.g. `NewExpr.toSchema` strict mode builds a - * union with branches per built-in class + per named instance). - */ - registry: Registry; - newStrict?: boolean; +/** + * Options consumed by `Type.toValueSchema` (and the helpers it delegates + * to like `describeType`). Deliberately narrower than `SchemaOptions`: + * value-side schema generation never references `Type` / `Expr` / + * `types` / `exprs` / `registry` / `newStrict`, so requiring them all + * just to pass `{ includeDocs: 'all' }` is overkill — and forces every + * caller to plumb the full meta-language schema bag through. + * + * Callers building a value-side schema for one Type (e.g. ginny's + * `test()` tool deriving its `args` schema from a function's params) + * can call `argsType.toValueSchema({ includeDocs: 'all' })` without + * holding onto the full `SchemaOptions`. `SchemaOptions` extends this, + * so existing call sites that already have the full bag still work. + */ +export interface ValueSchemaOptions { /** * Control whether Type docstrings are attached to generated Zod schemas * via `.describe(...)`. Useful for LLM prompting — docs become part of @@ -44,6 +48,35 @@ export interface SchemaOptions { * with its own `docs`. */ includeDocs?: 'none' | 'type' | 'all'; + /** + * Optional pass-through to the full meta-language schema bag. Most + * `toValueSchema` paths never touch these — they're declared here so + * a `SchemaOptions` (where these are required) is structurally + * assignable to `ValueSchemaOptions` without casts, and so the rare + * type that DOES need them (e.g. `TypType.toValueSchema` building an + * inline-Extension branch) can read them off `opts` directly when + * present and gracefully degrade when not. + */ + Type?: z.ZodTypeAny; + Expr?: z.ZodTypeAny; + types?: Type[]; + exprs?: Expr[]; + registry?: Registry; + newStrict?: boolean; +} + +export interface SchemaOptions extends ValueSchemaOptions { + Type: z.ZodTypeAny; + Expr: z.ZodTypeAny; + types: Type[]; + exprs: Expr[]; + /** + * Registry reference so schema builders can enumerate classes and + * registered named types (e.g. `NewExpr.toSchema` strict mode builds a + * union with branches per built-in class + per named instance). + */ + registry: Registry; + newStrict?: boolean; /** * Control whether Expr comments are attached via `.describe(...)`. * - `'none'` (default): ignore. @@ -75,6 +108,16 @@ export interface SchemaOptions { export interface CodeOptions { expectsValue?: boolean; indent?: string; + /** + * When false, suppress all `/* docs * /` and `// comment` rendering — + * Type docstrings, Prop docs, Expr comments, and `// docs` lines on + * Init / Call / Prop in `toCodeDefinition`. Default true (include). + * + * Threading this through inner `.toCode(...)` / `.toCodeDefinition(...)` + * calls is the responsibility of each composite type / expr — callers + * that want a comment-free render set this once at the top. + */ + includeComments?: boolean; } /** @@ -82,9 +125,51 @@ export interface CodeOptions { * validated. Both Type and Expr conform to this. */ export interface Node { - /** Render as TypeScript-like source text. */ + /** + * Render as TypeScript-like source text. Convenience wrapper that + * delegates to `toGinCode(...).toString()`. Existing callers that + * just want a string keep working unchanged. + */ toCode(registry?: Registry, options?: CodeOptions): string; + /** + * Render as gin's TS-pseudocode form (the same format `toCode` + * emits) but as a structured `Code` value carrying spans that tie + * each rendered range back to its node + validator path. Used by + * `formatProblem` to emit compiler-style `^^^` underlines for + * validation errors. + * + * The `path` argument is the validator-style path prefix where + * this node sits in its parent — composite renderers thread + * `[...path, segment]` into each child's `toGinCode` call so the + * resulting span paths line up with `Problem.path` exactly. + * + * Future: a sibling `toTypescriptCode` would emit real TypeScript + * with the same Code shape. + */ + toGinCode( + registry?: Registry, + options?: CodeOptions, + path?: ReadonlyArray, + ): Code; + + /** + * Render as the JSON form (the same shape `toJSON` emits, formatted + * with the same indentation as `JSON.stringify(..., null, 2)`) as a + * structured `Code` carrying spans aligned to JSON-token positions. + * Lets the caller surface validation errors in the JSON the LLM + * actually wrote. + */ + toJSONCode( + path?: ReadonlyArray, + indent?: number, + /** Current nesting depth — used by the indentation arithmetic so a + * child rendered inside its parent's `code\`...\`` indents its + * continuation lines correctly. Public callers leave at default 0; + * composite renderers pass `level + 1` to each child. */ + level?: number, + ): Code; + /** Serialize to its JSON shape (TypeDef for Type, ExprDef for Expr). */ toJSON(): unknown; diff --git a/packages/gin/src/path.ts b/packages/gin/src/path.ts index c0a2ff9c..4d4ed15c 100644 --- a/packages/gin/src/path.ts +++ b/packages/gin/src/path.ts @@ -5,13 +5,48 @@ import type { TypeDef, } from './schema'; import { Value, val } from './value'; -import { ThrowSignal } from './flow-control'; -import type { GetSet, Type } from './type'; +import { ReturnSignal, ThrowSignal } from './flow-control'; +import type { Call, GetSet, Prop, Type } from './type'; import { Expr } from './expr'; import type { Registry } from './registry'; -import type { TypeScope } from './analysis'; +import type { Locals } from './analysis'; import type { Problems } from './problem'; import type { ValidateContext } from './expr'; +import { LocalScope, type TypeScope } from './type-scope'; +import { joinAuto } from './type'; +import { ObjType } from './types/obj'; +import type { CodeOptions } from './node'; +import { Code, code, span, joinCode, jsonObject, jsonArray, jsonString } from './code'; + +/** + * `true` when accessing a prop whose type is a callable (fn / method) + * with no required arguments — every field on the args obj is either + * `optional<...>` or absent. We auto-invoke such callables on prop + * read, so e.g. `optional.has` resolves to the bool result of + * `has()` instead of the bare function value. Saves callers from a + * trailing `{args: {}}` step that's always empty by definition. + * + * Only methods (props) auto-call — standalone fn-typed scope vars + * (`recurse`, user-bound function values) keep the existing + * "function-value on read, called only via explicit `{args: ...}`" + * semantics. The walker draws that line by checking whether the + * resolution came from a prop access (`current` non-null) vs a + * scope lookup (`current === null`); auto-call only runs in the + * prop-access branch. + */ +function isAutoCallable(propType: Type, scope?: TypeScope): boolean { + const call = propType.call(scope); + if (!call) return false; + const args = call.args; + if (!(args instanceof ObjType)) { + // Empty / no-args fn — auto-callable. + return true; + } + for (const prop of Object.values(args.fields)) { + if (!prop.type.isOptional()) return false; + } + return true; +} /** * Path — a sequence of steps against a starting value. The third citizen @@ -35,16 +70,17 @@ export abstract class PathStep { abstract toJSON(): PathStepDef; abstract clone(): PathStep; - static from(json: PathStepDef, registry: Registry): PathStep { + static from(json: PathStepDef, scope: TypeScope): PathStep { if ('prop' in json) return new PropStep(json.prop); + const r = scope.registry; if ('args' in json) { const args: Record = {}; for (const [k, v] of Object.entries(json.args ?? {})) { - args[k] = registry.parseExpr(v); + args[k] = r.parseExpr(v, scope); } - return new CallStep(args, json.generic, json.catch ? registry.parseExpr(json.catch) : undefined); + return new CallStep(args, json.generic, json.catch ? r.parseExpr(json.catch, scope) : undefined); } - if ('key' in json) return new IndexStep(registry.parseExpr((json as PathIndexDef).key)); + if ('key' in json) return new IndexStep(r.parseExpr((json as PathIndexDef).key, scope)); throw new Error(`PathStep.from: unknown step shape`); } } @@ -81,20 +117,55 @@ export class CallStep extends PathStep { return new CallStep(args, this.generic, this.catch_?.clone()); } - /** Apply this step's generic bindings to the given callable type. */ - bindGeneric(calledType: Type, engine: Engine): Type { - if (!this.generic || Object.keys(this.generic).length === 0) return calledType; + /** Build a TypeScope of this step's generic bindings layered on top + * of `calledType.scope`. Returns the called type's scope verbatim + * when there are no bindings. Threaded through type-resolution + * methods (`call`, `parse`, etc.) at the call site so AliasTypes + * inside the called signature resolve to the bound types without + * rebuilding the type tree. + * + * Validates each binding against its declared constraint + * (`calledType.generic[name]`). A binding `T` is accepted iff + * `constraint.compatible(T)` — equivalently, `T` is assignable to + * the constraint. Throws on violation: parsing the binding into + * the call scope before that check would silently use an unsound + * type, so failing fast is the right call. + * + * Bindings for generic names the called type didn't declare are + * parsed into the call scope but not validated (they may target + * aliases declared on `call.types` or simply be ignored). */ + callSiteScope(calledType: Type): TypeScope { + if (!this.generic || Object.keys(this.generic).length === 0) { + return calledType.scope; + } const bindings: Record = {}; + const declaredGenerics = calledType.generic ?? {}; + // Parse each binding TypeDef in the called type's own scope so + // intra-binding name lookups (e.g. R: list) resolve naturally. for (const [k, def] of Object.entries(this.generic)) { - bindings[k] = engine.registry.parse(def); + const bound = calledType.scope.parse(def); + const constraint = declaredGenerics[k]; + if (constraint) { + // Self-referential placeholder (e.g. `R: alias('R')`) means + // "no constraint" — skip the satisfies check, every binding + // is accepted. + const isSelfRef = constraint.name === 'alias' + && (constraint.options as { name?: string } | undefined)?.name === k; + if (!isSelfRef && !constraint.compatible(bound)) { + throw new Error( + `path: generic '${k}' binding '${bound.toCode()}' does not satisfy constraint '${constraint.toCode()}'`, + ); + } + } + bindings[k] = bound; } - return calledType.bind(bindings); + return new LocalScope(calledType.scope, bindings); } /** Evaluate all arg Exprs against `scope` and return a Value. */ async buildArgsValue(calledType: Type, scope: Scope, engine: Engine): Promise { - const effectiveType = this.bindGeneric(calledType, engine); - const callable = effectiveType.call?.(); + const callScope = this.callSiteScope(calledType); + const callable = calledType.call?.(callScope); const argsType = callable?.args ?? engine.registry.obj({}); const raw: Record = {}; for (const [name, expr] of Object.entries(this.args)) { @@ -117,14 +188,32 @@ export class IndexStep extends PathStep { export class Path { constructor(readonly steps: ReadonlyArray) {} - static from(json: PathDef, registry: Registry): Path { - return new Path(json.map((s) => PathStep.from(s, registry))); + static from(json: PathDef, scope: TypeScope): Path { + return new Path(json.map((s) => PathStep.from(s, scope))); } toJSON(): PathDef { return this.steps.map((s) => s.toJSON()); } + /** + * `Code`-rendered JSON form of this path. Each step gets a span at + * `[...path, i]`; nested arg / key / catch sub-Exprs nest deeper to + * match validator-emitted paths during a path validateWalk. Used by + * `GetExpr.toJSONCode` / `SetExpr.toJSONCode`. + */ + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + const items = this.steps.map((step, i) => { + const stepPath = [...path, i] as const; + return renderPathStepJSON(step, stepPath, indent, level + 1); + }); + return jsonArray(items, { path }, level, indent); + } + clone(): Path { return new Path(this.steps.map((s) => s.clone())); } @@ -141,24 +230,61 @@ export class Path { } } - toCode(registry: Registry): string { - if (this.steps.length === 0) return ''; - let out = ''; + toCode(registry: Registry, options: CodeOptions = {}): string { + return this.toGinCode(registry, options).toString(); + } + + /** + * `Code`-aware path render. Every step gets a span at `[...path, + * 'path', i]`; nested arg / key / catch sub-Exprs get their own + * deeper spans matching what the validator emits during a path + * validateWalk. Used by GetExpr / SetExpr (which delegate path + * rendering to Path). + */ + toGinCode(registry: Registry, options: CodeOptions = {}, path: ReadonlyArray = []): Code { + if (this.steps.length === 0) return new Code(''); + let out: Code = new Code(''); for (let i = 0; i < this.steps.length; i++) { const step = this.steps[i]!; + const stepPath = [...path, 'path', i] as const; if (step instanceof PropStep) { - out = i === 0 ? step.prop : `${out}.${step.prop}`; + const stepText = i === 0 ? step.prop : `.${step.prop}`; + out = i === 0 + ? span(stepText, { path: stepPath }) + : code`${out}${span(stepText, { path: stepPath })}`; } else if (step instanceof CallStep) { const entries = Object.entries(step.args); - const body = entries.length === 0 - ? '{}' - : `{ ${entries.map(([k, v]) => `${k}: ${v.toCode(registry)}`).join(', ')} }`; - out += `(${body})`; + let callBody: Code; + if (entries.length === 0) { + callBody = new Code('()'); + } else { + const parts: Code[] = entries.map(([k, v]) => { + const argPath = [...stepPath, 'args', k] as const; + const valCode = v.toGinCode(registry, { ...options, expectsValue: true }, argPath); + return code`${k}: ${valCode}`; + }); + // Reuse joinAuto's heuristic on the joined string form. + const joined = joinAuto(parts.map((p) => p.toString())); + if (joined.startsWith('\n')) { + // Wrapped form: `({\n a,\n b,\n c\n})`. The separator + // already places 2 spaces of indent before each non-first + // entry; only the first entry needs its own leading + // indent (supplied by the outer template's `\n `). + const inner = joinCode(parts, ',\n '); + callBody = code`({\n ${inner}\n})`; + } else { + const inner = joinCode(parts, ', '); + callBody = code`({ ${inner} })`; + } + } + out = code`${out}${span(callBody, { path: stepPath })}`; if (step.catch_) { - out += ` /* catch: ${step.catch_.toCode(registry).replace(/\*\//g, '*_/')} */`; + const handler = step.catch_.toGinCode(registry, { ...options, expectsValue: true }, [...stepPath, 'catch']); + out = code`${out}.catch((error) => ${handler})`; } } else if (step instanceof IndexStep) { - out += `[${step.key.toCode(registry)}]`; + const key = step.key.toGinCode(registry, { ...options, expectsValue: true }, [...stepPath, 'key']); + out = code`${out}${span(code`[${key}]`, { path: stepPath })}`; } } return out; @@ -207,16 +333,15 @@ export class Path { const next = this.steps[i + 1]; const nextIsCall = next instanceof CallStep; if (nextIsCall && prop.type.call()) { - const effectiveFnType = (next as CallStep).bindGeneric(prop.type, engine); const argsValue = await (next as CallStep).buildArgsValue(prop.type, scope, engine); if (i + 1 === this.steps.length - 1 && mode.mode === 'set') { - await prop.invokeMethodSet(current, step.prop, argsValue, mode.setValue!, scope, engine, effectiveFnType); + await prop.invokeMethodSet(current, step.prop, argsValue, mode.setValue!, scope, engine, prop.type); return okSet(); } try { - current = await prop.invokeMethod(current, step.prop, argsValue, scope, engine, effectiveFnType); + current = await prop.invokeMethod(current, step.prop, argsValue, scope, engine, prop.type); } catch (sig) { if (sig instanceof ThrowSignal && (next as CallStep).catch_) { const c = scope.child({ error: sig.error }); @@ -229,6 +354,23 @@ export class Path { continue; } + // Auto-call: prop is a method with no required args, no + // explicit `{args: ...}` step follows. Synthesize an empty- + // args call so `optional.has` reads as the bool result, + // not the bare function value. Only triggers in the + // prop-access branch (current !== null) — standalone fn vars + // in scope still resolve to their function value. + if (!nextIsCall && isAutoCallable(prop.type)) { + const argsValue = await new CallStep({}, undefined, undefined).buildArgsValue(prop.type, scope, engine); + if (isLast && mode.mode === 'set') { + await prop.invokeMethodSet(current, step.prop, argsValue, mode.setValue!, scope, engine, prop.type); + return okSet(); + } + current = await prop.invokeMethod(current, step.prop, argsValue, scope, engine, prop.type); + i++; + continue; + } + if (isLast && mode.mode === 'set') { await prop.write(current, step.prop, mode.setValue!, scope, engine); return okSet(); @@ -283,9 +425,19 @@ export class Path { } else if (callSpec?.get) { const getterCallable = async (newArgs: Value): Promise => { const recurseValue = new Value(callType, getterCallable); - return engine.evaluate(callSpec.get!, scope.child({ - args: newArgs, recurse: recurseValue, - })); + // Catch ReturnSignal so a saved fn body using `flow:'return'` + // unwinds to its own call boundary (not all the way out + // through the caller's enclosing lambda). + try { + return await engine.evaluate(callSpec.get!, scope.child({ + args: newArgs, recurse: recurseValue, + })); + } catch (sig) { + if (sig instanceof ReturnSignal) { + return sig.value ?? val(engine.registry.void(), undefined); + } + throw sig; + } }; current = await getterCallable(argsValue); } else { @@ -311,7 +463,7 @@ export class Path { // ─── STATIC ANALYSIS ───────────────────────────────────────────────────── - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { if (this.steps.length === 0) return engine.registry.any(); let current: Type | null = null; let i = 0; @@ -325,17 +477,28 @@ export class Path { i++; continue; } - const p = current.prop(step.prop); - if (!p) return engine.registry.any(); + const propI: Prop | undefined = current.prop(step.prop); + if (!propI) return engine.registry.any(); const next = this.steps[i + 1]; - if (next instanceof CallStep && p.type.call()) { - const effective = next.bindGeneric(p.type, engine); - current = effective.call()?.returns ?? engine.registry.any(); + if (next instanceof CallStep && propI.type.call()) { + const callScope: TypeScope = next.callSiteScope(propI.type); + const ret: Type | undefined = propI.type.call(callScope)?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); i += 2; continue; } - current = p.type; + // Auto-call zero-required-arg methods on prop access (see + // `isAutoCallable` for the rule). Mirrors the runtime branch + // in `evaluate` so static type inference matches what the + // program will actually return. + if (!(next instanceof CallStep) && isAutoCallable(propI.type)) { + const ret: Type | undefined = propI.type.call()?.returns; + current = ret ?? engine.registry.any(); + i++; + continue; + } + current = propI.type; i++; continue; } @@ -347,8 +510,13 @@ export class Path { } if (step instanceof CallStep) { - const effective: Type | undefined = current ? step.bindGeneric(current, engine) : undefined; - current = effective?.call()?.returns ?? engine.registry.any(); + if (current) { + const callScope: TypeScope = step.callSiteScope(current); + const ret: Type | undefined = current.call(callScope)?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); + } else { + current = engine.registry.any(); + } i++; continue; } @@ -360,7 +528,7 @@ export class Path { validateWalk( engine: Engine, - scope: TypeScope, + scope: Locals, p: Problems, ctx: ValidateContext, mode: 'get' | 'set' = 'get', @@ -393,37 +561,63 @@ export class Path { i++; continue; } - const pp = current.prop(step.prop); - if (!pp) { + const propV: Prop | undefined = current.prop(step.prop); + if (!propV) { p.at(['path', i], () => p.error('prop.unknown', `no prop '${step.prop}' on type '${current!.name}'`)); current = engine.registry.any(); i++; continue; } const next = this.steps[i + 1]; - if (next instanceof CallStep && pp.type.call()) { + if (next instanceof CallStep && propV.type.call()) { for (const [name, argExpr] of Object.entries(next.args)) { p.at(['path', i + 1, 'args', name], () => argExpr.validateWalk(engine, scope, p, ctx)); } if (next.catch_) { p.at(['path', i + 1, 'catch'], () => next.catch_!.validateWalk(engine, scope, p, ctx)); } - const effective = next.bindGeneric(pp.type, engine); + const callScope: TypeScope = next.callSiteScope(propV.type); + const callable: Call | undefined = propV.type.call(callScope); if (mode === 'set' && i + 1 === this.steps.length - 1) { - if (!effective.call()?.set) { + if (!callable?.set) { p.at(['path', i + 1], () => p.error('set.call.no-set', `method '${step.prop}' has no call.set`)); } } - current = effective.call()?.returns ?? engine.registry.any(); + const ret: Type | undefined = callable?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); i += 2; continue; } + // Auto-call zero-required-arg methods on prop access: mirrors + // `evaluate` and `typeOf` so a program like `args.opt.has` + // type-checks as bool (the call's return) instead of fn. + if (!(next instanceof CallStep) && isAutoCallable(propV.type)) { + const ret: Type | undefined = propV.type.call()?.returns; + current = ret ?? engine.registry.any(); + i++; + continue; + } if (mode === 'set' && isLast) { - if (!pp.set) { - p.at(['path', i], () => p.error('set.prop.no-set', `prop '${step.prop}' has no set expression`)); + // Writing to a prop is allowed unless it's genuinely + // impossible. The only impossible case is a computed + // VALUE prop — `get` Expr present, no `set` Expr, and the + // prop's type is NOT callable. For those, the read is + // derived from `this` (no underlying slot to write into). + // + // Method-typed props (`propV.type.call()`) are NOT flagged + // here even if `propV.set` is missing — `propV.type.call() + // .set` could route the assignment through the call's own + // setter, or an extension could add a custom `propV.set`, + // or the runtime can fall through to a raw assignment. + // The validator doesn't have enough information at this + // step to decide; the runtime surfaces a clear error if + // the assignment is genuinely impossible. + if (!propV.set && propV.get && !propV.type.call()) { + p.at(['path', i], () => p.error('set.prop.computed', + `prop '${step.prop}' is computed (read-only); cannot assign to it`)); } } - current = pp.type; + current = propV.type; i++; continue; } @@ -447,13 +641,19 @@ export class Path { for (const [name, argExpr] of Object.entries(step.args)) { p.at(['path', i, 'args', name], () => argExpr.validateWalk(engine, scope, p, ctx)); } - const effective: Type | undefined = current ? step.bindGeneric(current, engine) : undefined; - if (mode === 'set' && isLast) { - if (!effective?.call()?.set) { - p.at(['path', i], () => p.error('set.call.no-set', `call on type '${current?.name ?? '?'}' has no call.set`)); + if (current) { + const callScope: TypeScope = step.callSiteScope(current); + const callable: Call | undefined = current.call(callScope); + if (mode === 'set' && isLast) { + if (!callable?.set) { + p.at(['path', i], () => p.error('set.call.no-set', `call on type '${current?.name ?? '?'}' has no call.set`)); + } } + const ret: Type | undefined = callable?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); + } else { + current = engine.registry.any(); } - current = effective?.call()?.returns ?? engine.registry.any(); i++; continue; } @@ -468,11 +668,11 @@ function isEmpty(v: Value): boolean { return v.raw === null || v.raw === undefined; } -// ─── legacy walkPath wrapper ──────────────────────────────────────────────── +// ─── walkPath helper ──────────────────────────────────────────────────────── /** - * Backwards-compat: accepts a raw PathDef JSON and walks it. - * Parses through Path.from for structured traversal. + * Convenience: accepts a raw PathDef JSON or an already-parsed `Path`, + * parses if needed, and walks it. */ export async function walkPath( path: PathDef | Path, @@ -483,3 +683,68 @@ export async function walkPath( const p = path instanceof Path ? path : Path.from(path, engine.registry); return p.walk(scope, engine, mode); } + +/** + * Render a single `PathStep` as a JSON object Code. Each step has its + * own JSON shape: + * - PropStep → `{ "prop": "" }` + * - CallStep → `{ "args": { ... } [, "generic": ...] [, "catch": ...] }` + * - IndexStep → `{ "key": }` + * + * Sub-Exprs (args, catch, key) recurse into their own `toJSONCode` + * with appropriate path suffixes so a `template.placeholder.unresolved` + * inside a `catch` block, for example, resolves to the right span. + */ +function renderPathStepJSON( + step: PathStep, + stepPath: ReadonlyArray, + indent: number, + level: number, +): Code { + if (step instanceof PropStep) { + return jsonObject( + [{ key: 'prop', value: jsonString(step.prop) }], + { path: stepPath }, + level, + indent, + ); + } + if (step instanceof CallStep) { + const argEntries = Object.entries(step.args).map(([k, expr]) => ({ + key: k, + value: expr.toJSONCode([...stepPath, 'args', k], indent, level + 2), + })); + const argsObj = jsonObject(argEntries, { path: [...stepPath, 'args'] }, level + 1, indent); + const entries: Array<{ key: string; value: Code | string | undefined }> = [ + { key: 'args', value: argsObj }, + ]; + if (step.generic) { + // `generic` is a Record. We don't span-track + // individual TypeDef positions here — the validator currently + // emits errors at the call-step level, not into individual + // generic bindings. Inline as plain JSON; coarse-span fallback. + entries.push({ + key: 'generic', + value: JSON.stringify(step.generic, null, indent) + .replace(/\n/g, '\n' + ' '.repeat(level * indent)), + }); + } + if (step.catch_) { + entries.push({ + key: 'catch', + value: step.catch_.toJSONCode([...stepPath, 'catch'], indent, level + 1), + }); + } + return jsonObject(entries, { path: stepPath }, level, indent); + } + if (step instanceof IndexStep) { + return jsonObject( + [{ key: 'key', value: step.key.toJSONCode([...stepPath, 'key'], indent, level + 1) }], + { path: stepPath }, + level, + indent, + ); + } + // Unknown step kind — fall back to generic JSON.stringify. + return new Code(JSON.stringify(step.toJSON(), null, indent)); +} diff --git a/packages/gin/src/registry.ts b/packages/gin/src/registry.ts index eba1432d..8e37c26f 100644 --- a/packages/gin/src/registry.ts +++ b/packages/gin/src/registry.ts @@ -1,5 +1,5 @@ import type { ExprDef, TypeDef } from './schema'; -import { Prop, Type } from './type'; +import { Call, GetSet, Init, Prop, type PropSpec, Type } from './type'; import { Extension, type ExtensionLocal } from './extension'; import { type BoolOptions, @@ -16,18 +16,21 @@ import { import { decodeCall, decodeGetSet, decodeInit, decodeProps } from './spec'; import { Expr, type ExprClass } from './expr'; import type { CodeOptions, SchemaOptions } from './node'; +import type { Code } from './code'; import type { JSONValue } from './json-type'; +import type { Engine } from './engine'; +import { Problems } from './problem'; import type { z } from 'zod'; import { AnyType } from './types/any'; import { AndType } from './types/and'; +import { AliasType } from './types/alias'; import { BoolType } from './types/bool'; import { ColorType } from './types/color'; import { DateType } from './types/date'; import { DurationType } from './types/duration'; import { EnumType } from './types/enum'; import { FnType } from './types/fn'; -import { GenericType } from './types/generic'; import { IfaceType } from './types/iface'; import { LiteralType } from './types/literal'; import { ListType } from './types/list'; @@ -39,13 +42,13 @@ import { NumType } from './types/num'; import { ObjType } from './types/obj'; import { OptionalType } from './types/optional'; import { OrType } from './types/or'; -import { RefType } from './types/ref'; import { TextType } from './types/text'; import { TimestampType } from './types/timestamp'; import { TupleType } from './types/tuple'; import { TypType } from './types/typ'; import { VoidType } from './types/void'; import type { Scope } from './scope'; +import type { TypeScope } from './type-scope'; import { Value } from './value'; import { registerBuiltinNatives } from './natives'; @@ -67,7 +70,13 @@ import { registerBuiltinNatives } from './natives'; export interface TypeClass { readonly NAME: string; readonly consumes?: readonly CustomField[]; - from(json: TypeDef, registry: Registry): Type; + /** Build a Type from its JSON. `scope` is the type-name resolution + * scope (Registry as the root, LocalScope layers above for fn + * generics / call.types aliases). Use `scope.registry` to access + * the underlying Registry for child-type construction; use + * `scope.parse` (i.e. `scope.registry.parse(child, scope)`) to + * recursively parse children with the same scope. */ + from(json: TypeDef, scope: TypeScope): Type; /** JSON-shape Zod schema for this Type's TypeDef. */ toSchema(opts: SchemaOptions): z.ZodTypeAny; /** @@ -89,8 +98,9 @@ export type CustomField = 'props' | 'get' | 'call' | 'init'; const ALL_CUSTOM_FIELDS: readonly CustomField[] = ['props', 'get', 'call', 'init']; /** Native implementation — the actual JS function that runs a native op. - * Receives the current scope plus the registry for convenient access to - * built-in types when wrapping a returned raw back into a Value. */ + * Receives the current runtime scope plus the registry for convenient + * access to built-in types when wrapping a returned raw back into a + * Value. */ export type NativeImpl = (scope: Scope, registry: Registry) => Value | unknown | Promise; /** @@ -106,11 +116,46 @@ export type NativeImpl = (scope: Scope, registry: Registry) => Value | unknown | * to each class's static `from` method, and recurses through nested types * via the same entry point. */ -export class Registry implements TypeBuilder { +/** + * Augmentations a developer can attach to a Type by name (e.g. 'num', + * 'text', 'Email'). Stored on the Registry; consulted by every Type's + * `props()` / `get()` / `call()` / `init()` so the additions are + * visible at runtime path-walks, in static analysis, and in code + * rendering — without subclassing or wrapping the type in an + * Extension. + * + * Composition rules: + * - `props`: ADDED to the type's intrinsic props. Intrinsic names + * win on conflict (you can't replace `num.add` via augmentation). + * Use this to introduce NEW methods / fields. + * - `get` / `call` / `init`: applied IFF the type has none of its + * own. Augmentation can introduce a missing surface (e.g. give + * `date` a `get/loop`, make `timestamp` callable, give `text` an + * init constructor) but does not override one the type already + * declares. + * + * Augmentations are accumulated — multiple calls to `registry.augment` + * for the same name MERGE props. The first `get`/`call`/`init` that's + * defined wins (subsequent attempts to set the same field are no-ops). + */ +export interface TypeAugmentation { + props?: Record; + get?: GetSet; + call?: Call; + init?: Init; +} + +export class Registry implements TypeBuilder, TypeScope { private readonly classes = new Map(); private readonly namedTypes = new Map(); private readonly natives = new Map(); private readonly exprClasses = new Map(); + private readonly augments = new Map(); + + // ─── SCOPE INTERFACE ───────────────────────────────────────────────────── + /** Registry IS the root scope. */ + readonly parent: undefined = undefined; + get registry(): Registry { return this; } // ─── CLASS REGISTRATION ────────────────────────────────────────────────── @@ -126,7 +171,61 @@ export class Registry implements TypeBuilder { return this; } - /** Look up a named Type by name. Registered instances win over defaults. */ + /** + * Add methods / get / call / init to an existing type by name — + * works for both built-in classes (`'num'`, `'text'`, `'date'`, + * `'timestamp'`, ...) and named instances / Extensions you've + * registered. Repeated calls for the same name MERGE: props are + * accumulated, while get/call/init keep their first non-undefined + * value (subsequent attempts to redefine those are silently + * ignored — augmentations fill gaps, they don't override). + * + * Example — give `date` a `get/loop` so you can iterate over a + * range, and make `timestamp` callable as a fn: + * ```ts + * registry.augment('date', { get: new GetSet({ key: registry.num(), value: registry.date(), loop: ... }) }); + * registry.augment('timestamp', { call: new Call({ args: ..., returns: ... }) }); + * ``` + * + * Augmented surface flows through every consumer: path-walker + * dispatches against augmented `props` / `get` / `call`, + * `validateWalk` static analysis sees them, `toCodeDefinition` + * renders them in the type's surface block. + */ + augment(name: string, addition: TypeAugmentation): this { + const cur = this.augments.get(name); + if (!cur) { + this.augments.set(name, { + props: addition.props ? { ...addition.props } : undefined, + get: addition.get, + call: addition.call, + init: addition.init, + }); + return this; + } + // Merge into the existing augmentation. Props additive (new wins + // on per-name conflict within augmentation itself, but intrinsic + // type props still win at consumption time). get/call/init are + // first-wins — once set, further attempts no-op. + this.augments.set(name, { + props: addition.props ? { ...(cur.props ?? {}), ...addition.props } : cur.props, + get: cur.get ?? addition.get, + call: cur.call ?? addition.call, + init: cur.init ?? addition.init, + }); + return this; + } + + /** Read the registered augmentation for a type-by-name. Returns + * undefined when nothing has been augmented. Used by `Type.props` + * / `Type.get` / `Type.call` / `Type.init` to overlay additions. */ + augmentation(name: string): TypeAugmentation | undefined { + return this.augments.get(name); + } + + /** Look up a Type by name. Registered named instances win; falls back + * to built-in classes (synthesized canonical instance). Returns + * undefined for unknown names. Implements `TypeScope.lookup`. */ lookup(name: string): Type | undefined { if (this.namedTypes.has(name)) return this.namedTypes.get(name); const cls = this.classes.get(name); @@ -134,6 +233,11 @@ export class Registry implements TypeBuilder { return undefined; } + /** Registry has no "local-above-root" layer. See TypeScope.localLookup. */ + localLookup(_name: string): Type | undefined { + return undefined; + } + // ─── NATIVES ───────────────────────────────────────────────────────────── setNative(id: string, impl: NativeImpl): this { @@ -176,8 +280,11 @@ export class Registry implements TypeBuilder { return Array.from(this.exprClasses.values()); } - /** Parse an ExprDef (or already-parsed Expr) into an Expr instance. */ - parseExpr(json: unknown): Expr { + /** Parse an ExprDef (or already-parsed Expr) into an Expr instance. + * Optional `scope` lets callers thread a `LocalScope` (with generic / + * call.types aliases bound) through Expr.from implementations that + * recurse into nested TypeDefs (`new`, `lambda`, `native`, `define`). */ + parseExpr(json: unknown, scope: TypeScope = this): Expr { if (json instanceof Expr) return json; if (!json || typeof json !== 'object' || !('kind' in (json as object))) { throw new Error(`registry.parseExpr: expected ExprDef with kind, got ${typeof json}`); @@ -185,7 +292,7 @@ export class Registry implements TypeBuilder { const def = json as ExprDef; const cls = this.exprClasses.get(def.kind); if (!cls) throw new Error(`registry.parseExpr: unknown expr kind '${def.kind}'`); - return cls.from(def, this); + return cls.from(def, scope); } /** @@ -197,6 +304,69 @@ export class Registry implements TypeBuilder { return e.toCode(this, options); } + /** + * Render an ExprDef (or parsed Expr) as gin's TS-pseudocode form + * with span annotations. The result's `Code` carries spans that + * line up with `Problem.path` from `engine.validate(...)`, so a + * caller can pass both into `formatProblem` / `formatProblems` to + * produce compiler-style `^^^` underlines. + */ + toGinCode(expr: ExprDef | Expr, options?: CodeOptions): Code { + const e = expr instanceof Expr ? expr : this.parseExpr(expr); + return e.toGinCode(this, options, []); + } + + /** + * Render an ExprDef (or parsed Expr) as the JSON form (same shape + * as `JSON.stringify(expr.toJSON(), null, 2)`) with spans on each + * structural slot. Used by ginny's `write` tool to surface + * validation pointers in the JSON the LLM actually emitted. + */ + toJSONCode(expr: ExprDef | Expr, indent: number = 2): Code { + const e = expr instanceof Expr ? expr : this.parseExpr(expr); + return e.toJSONCode([], indent); + } + + /** + * Validate every user-supplied piece of type surface attached to this + * registry — every named type (registered via `register(...)` / + * `extend(...)`) AND every augmented built-in (registered via + * `augment(name, ...)`). Each type's full surface (props / get / call + * / init) is walked; embedded ExprDefs are parsed and validated with + * the runtime scope they'll see (`this` / `args` / `recurse` / etc.). + * + * Returns a single `Problems` bag with paths prefixed by the type's + * name. Run as a sweep step after registering custom types — surface + * issues at registration time rather than at runtime when the method + * is first called. + * + * Programs validated via `engine.validate(programExpr)` do NOT + * trigger this sweep; the two passes are intentionally separate so a + * program walk doesn't redo work for every type it touches. + */ + validate(engine: Engine): Problems { + const out = new Problems(); + const seen = new Set(); + const visit = (typeName: string, type: Type): void => { + if (seen.has(typeName)) return; + seen.add(typeName); + const sub = type.validate(engine); + for (const prob of sub.list) { + out.list.push({ ...prob, path: [typeName, ...prob.path] }); + } + }; + // Registered named types (Extensions and explicit `register(...)`). + for (const [name, type] of this.namedTypes) visit(name, type); + // Built-ins that have been augmented in place. Build a canonical + // instance via `lookup` so the surface walker sees the same + // type object the runtime would dispatch against. + for (const name of this.augments.keys()) { + const t = this.lookup(name); + if (t) visit(name, t); + } + return out; + } + // ─── JSON PARSE ────────────────────────────────────────────────────────── /** @@ -204,30 +374,50 @@ export class Registry implements TypeBuilder { * of `Value.toJSON()` — decode the TypeDef via `parse`, then ask that * Type to parse the dumped value. */ - parseValue(json: unknown, expectedType?: Type): Value { + parseValue(json: unknown, expectedType?: Type, scope: TypeScope = this): Value { if (json instanceof Value) { return json; } if (json && typeof json === 'object' && 'type' in json && 'value' in json) { - return this.parse(json.type).parse(json.value); + return this.parse(json.type, scope).parse(json.value, scope); } if (!expectedType) { throw new TypeError(`registry.parseValue: expected Value or JSONValue, got ${typeof json}`); } - return expectedType.parse(json); + return expectedType.parse(json, scope); } - parse(json: unknown): Type { + parse(json: unknown, scope: TypeScope = this): Type { if (!json || typeof json !== 'object') { throw new Error(`registry.parse: expected object, got ${typeof json}`); } const def = json as TypeDef; - const result = this.parseInner(def); + // Type names must be \w+ (letters, digits, underscore — no + // whitespace, no punctuation). LLM-emitted TypeDefs sometimes + // arrive with leading whitespace or other junk in the name; the + // downstream "claims to satisfy X but does not structurally + // match" error is baffling because the offending whitespace is + // invisible. Reject explicitly here with a precise pointer. + if (typeof def.name !== 'string' || !/^\w+$/.test(def.name)) { + throw new Error(`registry.parse: type 'name' must match /^\\w+$/, got ${JSON.stringify(def.name)}`); + } + if (def.extends !== undefined && (typeof def.extends !== 'string' || !/^\w+$/.test(def.extends))) { + throw new Error(`registry.parse: type 'extends' must match /^\\w+$/, got ${JSON.stringify(def.extends)}`); + } + if (def.satisfies) { + for (const ifaceName of def.satisfies) { + if (typeof ifaceName !== 'string' || !/^\w+$/.test(ifaceName)) { + throw new Error(`registry.parse: 'satisfies' entries must match /^\\w+$/, got ${JSON.stringify(ifaceName)}`); + } + } + } + + const result = this.parseInner(def, scope); // `satisfies` claims: verify each against the named interface. if (def.satisfies && def.satisfies.length > 0) { for (const ifaceName of def.satisfies) { - const iface = this.lookup(ifaceName); + const iface = scope.lookup(ifaceName) ?? this.lookup(ifaceName); if (!iface) { throw new Error(`registry.parse: satisfies references unknown interface '${ifaceName}'`); } @@ -240,13 +430,50 @@ export class Registry implements TypeBuilder { return result; } - private parseInner(def: TypeDef): Type { + /** True if `def` is a bare-name shape: only `name` (and optionally + * `docs`), no structural peers. Bare-name defs route through scope + * lookup → AliasType / registered named type / canonical class. */ + private isBareNameDef(def: TypeDef): boolean { + const peers: ReadonlyArray = [ + 'extends', 'satisfies', 'generic', 'options', + 'init', 'props', 'get', 'call', 'constraint', + ]; + for (const k of peers) { + if ((def as unknown as Record)[k] !== undefined) return false; + } + return true; + } + + private parseInner(def: TypeDef, scope: TypeScope): Type { // `extends` indirection: build the base from the referenced name, wrap // in Extension with local additions/narrowings. if (def.extends) { - const base = this.lookup(def.extends); + const base = scope.lookup(def.extends) ?? this.lookup(def.extends); if (!base) throw new Error(`registry.parse: extends references unknown type '${def.extends}'`); - return new Extension(this, base, this.buildLocal(def)); + return new Extension(this, base, this.buildLocal(def, scope)); + } + + // Bare-name shape: dispatch via scope chain. + if (this.isBareNameDef(def)) { + // Walk above-registry layers — if the name is bound LOCALLY in + // any LocalScope (generic placeholder, call.types alias), wrap in + // AliasType so substitute / scope resolution works correctly. + let s: TypeScope | undefined = scope; + while (s && s !== this) { + if (s.localLookup(def.name) !== undefined) { + return new AliasType(scope, { name: def.name }); + } + s = s.parent; + } + // Registered named type — return directly (preserves instanceof). + if (this.namedTypes.has(def.name)) return this.namedTypes.get(def.name)!; + // Built-in class — dispatch eagerly to canonical instance. + const cls = this.classes.get(def.name); + if (cls) return cls.from(def, scope); + // Unknown name — AliasType (lazy; supports forward-refs to types + // registered later, e.g. self-referential `r.alias('Node')` during + // construction of Node). + return new AliasType(scope, { name: def.name }); } // Previously-registered named type (Extension or programmatically defined). @@ -261,19 +488,19 @@ export class Registry implements TypeBuilder { const consumed = new Set(cls.consumes ?? []); const leftover = ALL_CUSTOM_FIELDS.filter((f) => def[f] !== undefined && !consumed.has(f)); - if (leftover.length === 0) return cls.from(def, this); + if (leftover.length === 0) return cls.from(def, scope); const stripped: TypeDef = { ...def }; for (const f of leftover) delete stripped[f]; - const base = cls.from(stripped, this); + const base = cls.from(stripped, scope); const local: ExtensionLocal = { name: def.name, docs: def.docs, - props: leftover.includes('props') && def.props ? decodeProps(def.props, this) : undefined, - get: leftover.includes('get') && def.get ? decodeGetSet(def.get, this) : undefined, - call: leftover.includes('call') && def.call ? decodeCall(def.call, this) : undefined, - init: leftover.includes('init') && def.init ? decodeInit(def.init, this) : undefined, + props: leftover.includes('props') && def.props ? decodeProps(def.props, scope) : undefined, + get: leftover.includes('get') && def.get ? decodeGetSet(def.get, scope) : undefined, + call: leftover.includes('call') && def.call ? decodeCall(def.call, scope) : undefined, + init: leftover.includes('init') && def.init ? decodeInit(def.init, scope) : undefined, }; return new Extension(this, base, local); } @@ -293,7 +520,7 @@ export class Registry implements TypeBuilder { try { if (iface.compatible(t)) out.push(t); } catch { - // lazy proxies (ref/generic) may throw during compat — skip. + // lazy proxies (alias) may throw during compat — skip. } } @@ -316,10 +543,10 @@ export class Registry implements TypeBuilder { } /** Decode all customization fields from a TypeDef into an ExtensionLocal. */ - private buildLocal(def: TypeDef): ExtensionLocal { + private buildLocal(def: TypeDef, scope: TypeScope): ExtensionLocal { const generic = def.generic ? Object.fromEntries( - Object.entries(def.generic).map(([k, v]) => [k, this.parse(v)]), + Object.entries(def.generic).map(([k, v]) => [k, this.parse(v, scope)]), ) : undefined; return { @@ -327,11 +554,11 @@ export class Registry implements TypeBuilder { docs: def.docs, options: def.options, generic, - props: def.props ? decodeProps(def.props, this) : undefined, - get: def.get ? decodeGetSet(def.get, this) : undefined, - call: def.call ? decodeCall(def.call, this) : undefined, - init: def.init ? decodeInit(def.init, this) : undefined, - constraint: def.constraint ? this.parseExpr(def.constraint) : undefined, + props: def.props ? decodeProps(def.props, scope) : undefined, + get: def.get ? decodeGetSet(def.get, scope) : undefined, + call: def.call ? decodeCall(def.call, scope) : undefined, + init: def.init ? decodeInit(def.init, scope) : undefined, + constraint: def.constraint ? this.parseExpr(def.constraint, scope) : undefined, }; } @@ -396,8 +623,15 @@ export class Registry implements TypeBuilder { }); } - ref(name: string) { return new RefType(this, { name }); } - generic(name: string) { return new GenericType(this, { name }); } + + /** Bare-name reference / generic-parameter placeholder. + * JSON form is `{name: 'X'}` — interpretation depends on scope: + * resolves to a registered named type, a built-in class instance, a + * generic placeholder bound on the enclosing fn, or a `call.types` + * alias. Call-site specialization (e.g. path-step ``) + * passes an extra `TypeScope` at access time; AliasType.resolve + * consults it before its captured scope. No type-tree rebuild. */ + alias(name: string) { return new AliasType(this, { name }); } typ(constraint: Type): TypType { return new TypType(this, constraint); @@ -517,8 +751,6 @@ export const BUILTIN_TYPES: TypeClass[] = [ LiteralType, FnType, IfaceType, - RefType, - GenericType, TypType, DateType, TimestampType, diff --git a/packages/gin/src/schema.ts b/packages/gin/src/schema.ts index 3a8348ec..8fe3c0a7 100644 --- a/packages/gin/src/schema.ts +++ b/packages/gin/src/schema.ts @@ -42,10 +42,32 @@ export interface GetSetDef { get?: ExprDef; set?: ExprDef; loop?: ExprDef; + /** + * When true, `LoopExpr` re-evaluates `over` BEFORE every iteration + * and binds the resulting value to `value` (and the iteration index + * to `key`). The loop continues while `value.raw` is truthy and + * exits when it becomes falsy. With this flag the type does NOT + * need a `loop` native — gin's loop machinery iterates directly. + * + * Bool sets this to get while-loop semantics. Other types can opt + * in with whatever truthy semantic makes sense for their `raw` + * (optional → present, num → non-zero, etc.). + */ + loopDynamic?: boolean; } export interface CallDef { docs?: string; + /** + * Local type aliases scoped to THIS call. Each entry is a TypeDef + * referenced inside `args` / `returns` / `throws` / `get` / `set` via + * a bare `{name: ''}` reference. Aliases process AFTER + * the parent type's generics (so they may reference generic + * placeholders) and BEFORE the call slots (so the slots resolve + * against them). Sequential — later aliases may reference earlier. + * Inlining happens at parse time inside `decodeCall`. + */ + types?: Record; args: TypeDef; returns?: TypeDef; throws?: TypeDef; @@ -146,7 +168,15 @@ export interface LambdaExprDef extends ExprDef { export interface TemplateExprDef extends ExprDef { kind: 'template'; template: string; - params: ExprDef; // expression that evaluates to an object with param values + /** + * Optional expression evaluating to an obj whose props supply + * placeholder values. When omitted (or when a particular `{name}` + * key isn't on the obj), placeholders fall back to a scope lookup + * — so a `${baseUrl}` placeholder resolves to the surrounding + * `define` of the same name. Provide `params` only when the + * placeholders need values that aren't already in scope. + */ + params?: ExprDef; } export interface FlowExprDef extends ExprDef { diff --git a/packages/gin/src/schemas.ts b/packages/gin/src/schemas.ts index b22916e1..62dfaa2d 100644 --- a/packages/gin/src/schemas.ts +++ b/packages/gin/src/schemas.ts @@ -17,7 +17,13 @@ export type { SchemaOptions } from './node'; /** Shared ExprDef fields (just `comment`). */ export const baseExprFields: z.ZodRawShape = { - comment: z.string().optional().meta({ aid: 'Comment' }), + comment: z + .string() + .optional() + .describe( + 'Optional one-line note explaining why this expression exists. Travels with the node, surfaces in `toCode` as a `/* … */` annotation, and shows up in error paths. Use for non-obvious steps; skip for trivial reads.', + ) + .meta({ aid: 'Comment' }), }; /** Shared generic-parameter map (`{ [name]: Type }`). Used inline by @@ -30,13 +36,27 @@ export function genericSchema(opts: SchemaOptions): z.ZodTypeAny { /** Shared PathStep union used by GetExpr/SetExpr. */ export function pathStepSchema(opts: SchemaOptions): z.ZodTypeAny { return z.union([ - z.object({ prop: z.string() }), z.object({ - args: z.record(z.string(), opts.Expr), - generic: genericSchema(opts).optional(), - catch: opts.Expr.optional(), - }), - z.object({ key: opts.Expr }), + prop: z.string().describe( + "Named-property access. First step looks up a scope variable by name; subsequent steps read the previous value's prop / method. Reject if the name isn't on the type's `props()`.", + ), + }).describe('PROP step — `.` access (scope var on first step, prop/method on later steps).'), + z.object({ + args: z.record(z.string(), opts.Expr).describe( + 'Map of arg-name → ExprDef. Calls the previous step (a method or any callable). Each arg expression is evaluated in the caller scope before the call; the result obj is bound as `args` inside the call body.', + ), + generic: genericSchema(opts).optional().describe( + 'Optional generic-parameter map for parameterized callables (e.g. `list.map` binds `R` to the element type of the result list). Usually unnecessary — most callables infer generics.', + ), + catch: opts.Expr.optional().describe( + 'Optional handler expression evaluated if this call throws. The thrown value is bound under `error` in the handler scope.', + ), + }).describe('CALL step — invoke the previous step. Comes after a method (e.g. `list.push`) or any callable value.'), + z.object({ + key: opts.Expr.describe( + 'Indexed-access key expression. Evaluated at run time and passed to the previous value\'s `[key]` get/set surface.', + ), + }).describe('INDEX step — `[]` access for types with index signatures (lists by `num`, maps by their key type).'), ]).meta({ aid: 'PathStep' }); } @@ -60,6 +80,12 @@ export function getSetDefSchema(opts: SchemaOptions): z.ZodTypeAny { get: opts.Expr.optional(), set: opts.Expr.optional(), loop: opts.Expr.optional(), + loopDynamic: z + .boolean() + .optional() + .describe( + 'When true, `loop over: ` re-evaluates the expression each iteration and exits when the result\'s raw is falsy. Bool uses this for while-loop semantics. The type may have either `loop` (for static iterables) OR `loopDynamic` set; with loopDynamic, no `loop` ExprDef is required.', + ), }).meta({ aid: 'GetSetDef' }); } @@ -67,6 +93,14 @@ export function getSetDefSchema(opts: SchemaOptions): z.ZodTypeAny { export function callDefSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ docs: z.string().optional(), + types: z + .record(z.string(), opts.Type) + .optional() + .describe( + 'Call-local type aliases. Declare reusable named types here ONCE and reference them inside `args` / `returns` / `throws` / `get` / `set` as a bare `{name: ""}`. ' + + 'Aliases process AFTER any enclosing generics (so they may reference generic placeholders) and BEFORE the call slots — the call slots resolve them at parse time. ' + + 'Sequential: later aliases may reference earlier ones. Use this whenever the same composite type appears more than once in a signature — instead of writing `num{whole:true, min:1}` four times, declare `{ counter: { name:"num", options:{whole:true,min:1} } }` once and reference `{name:"counter"}`.', + ), args: opts.Type, returns: opts.Type.optional(), throws: opts.Type.optional(), @@ -120,11 +154,16 @@ export function extensionSchemaNarrowed( const extendsEnum = allowedNames.length > 0 ? z.enum(allowedNames as [string, ...string[]]) : z.never(); + // Type names are identifiers — letters, digits, underscore. No + // whitespace, no punctuation, no spaces. Catching this at the schema + // layer surfaces a precise zod path ("name: ...") instead of a + // baffling structural-match failure deep inside `registry.parse`. + const identifier = z.string().regex(/^\w+$/, 'must be a /\\w+/ identifier'); return z.object({ - name: z.string(), + name: identifier, extends: extendsEnum, docs: z.string().optional(), - satisfies: z.array(z.string()).optional(), + satisfies: z.array(identifier).optional(), generic: genericSchema(opts).optional(), options: z.record(z.string(), z.any()).optional(), props: z.record(z.string(), propDefSchema(opts)).optional(), diff --git a/packages/gin/src/scope.ts b/packages/gin/src/scope.ts index 297c8233..7d06f195 100644 --- a/packages/gin/src/scope.ts +++ b/packages/gin/src/scope.ts @@ -1,11 +1,30 @@ import type { Value } from './value'; +/** + * Names gin's runtime injects into child scopes for specific + * expression contexts. User-authored bindings (`DefineExpr.vars[].name`, + * `LoopExpr.keyName`/`valueName` overrides) MUST NOT use these — the + * engine will rebind them at the relevant context, silently shadowing + * the user's value and producing very confusing behavior. + * + * - `args` — function parameters (Lambda, path call.get/set, NewExpr init). + * - `recurse` — self-reference in fn bodies. + * - `this` — receiver in prop/method bodies, NewExpr init, loop.over. + * - `super` — base impl in prop/method overrides. + * - `key`, `value` — loop iteration bindings (default names). + * - `yield` — internal yield callback for loop bodies. + * - `error` — bound in path catch handlers. + */ +export const RESERVED_NAMES: ReadonlySet = new Set([ + 'args', 'recurse', 'this', 'super', 'key', 'value', 'yield', 'error', +]); + /** * Scope: lexical variable bindings with parent chain. * * Root scope contains globals. Each Define/Lambda/Loop creates a child. - * Reserved names (this, args, result, key, value, yield, super) are - * injected per-context, not by globals. + * Reserved names (see `RESERVED_NAMES`) are injected per-context, not + * by globals. */ export class Scope { readonly parent: Scope | null; diff --git a/packages/gin/src/spec.ts b/packages/gin/src/spec.ts index d73d9e7e..77c33259 100644 --- a/packages/gin/src/spec.ts +++ b/packages/gin/src/spec.ts @@ -1,140 +1,46 @@ import type { Registry } from './registry'; import { Call, GetSet, Init, Prop, type PropSpec, type Type } from './type'; import type { CallDef, GetSetDef, PropDef, TypeDef } from './schema'; - -// ─── generic substitution (TypeDef tree) ───────────────────────────────── - -/** - * Walk a TypeDef substituting generic placeholders per `bindings`. Fully - * polymorphic: parses each node into a Type and dispatches to its - * `.substitute(bindings)` method, then re-encodes. GenericType overrides - * substitute() to return its binding; every other type uses the default, - * which recurses into its common child fields (generic / props / get / - * call / init). No `name === 'generic'` check lives in this file. - * - * This helper is now a thin wrapper over Type.substitute — kept for - * backwards-compat and for the registry's parse-time use (e.g., Type.bind - * and programmatic substitution). - */ -export function substituteTypeDef( - def: TypeDef, - bindings: Record, - registry: Registry, -): TypeDef { - return registry.parse(def).substitute(bindings).toJSON(); -} - -/** - * Default child-walker used by Type.substitute: recursively substitutes - * the common child-type fields without knowing anything about the outer - * type's kind. Only invoked from Type.substitute — never from user code. - */ -export function substituteChildren( - def: TypeDef, - bindings: Record, - registry: Registry, -): TypeDef { - const next: TypeDef = { ...def }; - - if (def.generic) { - const g: Record = {}; - for (const [k, v] of Object.entries(def.generic)) { - g[k] = substituteTypeDef(v, bindings, registry); - } - next.generic = g; - } - - if (def.props) { - const p: Record = {}; - for (const [k, pd] of Object.entries(def.props)) { - p[k] = { ...pd, type: substituteTypeDef(pd.type, bindings, registry) }; - } - next.props = p; - } - - if (def.get) { - next.get = { - ...def.get, - key: substituteTypeDef(def.get.key, bindings, registry), - value: substituteTypeDef(def.get.value, bindings, registry), - }; - } - - if (def.call) { - next.call = { - ...def.call, - args: substituteTypeDef(def.call.args, bindings, registry), - returns: def.call.returns ? substituteTypeDef(def.call.returns, bindings, registry) : undefined, - throws: def.call.throws ? substituteTypeDef(def.call.throws, bindings, registry) : undefined, - }; - } - - if (def.init) { - next.init = { ...def.init, args: substituteTypeDef(def.init.args, bindings, registry) }; - } - - return next; -} +import { LocalScope, type TypeScope } from './type-scope'; /** * Runtime ↔ schema conversion for Prop/GetSet/Call/Init specs. * Runtime specs hold resolved Type instances; schema specs hold TypeDef JSON. - * Each concrete Type uses these when implementing encode() and parse/from. + * + * **Encoding lives on the runtime classes** as `toJSON()` methods — + * `Prop.toJSON()`, `GetSet.toJSON()`, `Call.toJSON()`, `Init.toJSON()`. + * Each concrete Type calls `.toJSON()` directly when implementing its + * own `toJSON()`. The free `encodeProps` helper below is the only + * survivor: it's a thin map-shim that normalizes `PropSpec`s to + * `Prop` instances before calling `.toJSON()`. + * + * Decoding (the reverse — JSON → runtime) lives here as free functions + * because each decode needs the registry to recurse into child types, + * and putting them as static methods on the runtime classes would mean + * every runtime class importing the registry. */ // ─── encode (runtime → schema) ──────────────────────────────────────────── -export function encodeProp(prop: Prop | PropSpec): PropDef { - return { - docs: prop.docs, - type: prop.type.toJSON(), - get: prop.get, - default: prop.default, - set: prop.set, - }; -} - +/** + * Map a record of Prop/PropSpec values to their JSON form. Normalizes + * each entry through `Prop.from` so `PropSpec` plain objects work + * alongside `Prop` instances. The only free encode function — the + * single-instance ones live as methods on the runtime classes. + */ export function encodeProps(props: Record): Record { const out: Record = {}; - for (const [name, prop] of Object.entries(props)) out[name] = encodeProp(prop); + for (const [name, prop] of Object.entries(props)) { + out[name] = Prop.from(prop).toJSON(); + } return out; } -export function encodeGetSet(gs: GetSet): GetSetDef { - return { - docs: gs.docs, - key: gs.key.toJSON(), - value: gs.value.toJSON(), - get: gs.get, - set: gs.set, - loop: gs.loop, - }; -} - -export function encodeCall(call: Call): CallDef { - return { - docs: call.docs, - args: call.args.toJSON(), - returns: call.returns?.toJSON(), - throws: call.throws?.toJSON(), - get: call.get, - set: call.set, - }; -} - -export function encodeInit(init: Init): NonNullable { - return { - docs: init.docs, - args: init.args.toJSON(), - run: init.run, - }; -} - // ─── decode (schema → runtime), recurses via registry ──────────────────── -export function decodeProp(def: PropDef, registry: Registry): Prop { +export function decodeProp(def: PropDef, scope: TypeScope): Prop { return new Prop({ - type: registry.parse(def.type), + type: scope.parse(def.type), get: def.get, set: def.set, default: def.default, @@ -142,37 +48,65 @@ export function decodeProp(def: PropDef, registry: Registry): Prop { }); } -export function decodeProps(defs: Record, registry: Registry): Record { +export function decodeProps( + defs: Record, + scope: TypeScope, +): Record { const out: Record = {}; - for (const [name, def] of Object.entries(defs)) out[name] = decodeProp(def, registry); + for (const [name, def] of Object.entries(defs)) out[name] = decodeProp(def, scope); return out; } -export function decodeGetSet(def: GetSetDef, registry: Registry): GetSet { +export function decodeGetSet(def: GetSetDef, scope: TypeScope): GetSet { return new GetSet({ - key: registry.parse(def.key), - value: registry.parse(def.value), + key: scope.parse(def.key), + value: scope.parse(def.value), get: def.get, set: def.set, loop: def.loop, + loopDynamic: def.loopDynamic, docs: def.docs, }); } -export function decodeCall(def: CallDef, registry: Registry): Call { +/** + * Decode a CallDef into a `Call`. When `def.types` is non-empty, build + * a `LocalScope` layered on top of `scope` and bind each alias to its + * (sequentially-parsed) Type — earlier aliases are visible to later + * ones and to the call's args/returns/throws/get/set. The call retains + * the alias map so `Call.toJSON()` can round-trip it. + */ +export function decodeCall(def: CallDef, scope: TypeScope): Call { + let inner: TypeScope = scope; + let aliases: Record | undefined; + if (def.types && Object.keys(def.types).length > 0) { + const local = new LocalScope(scope); + inner = local; + aliases = {}; + for (const [name, aliasDef] of Object.entries(def.types)) { + const t = local.parse(aliasDef); + local.bind(name, t); + aliases[name] = t; + } + } + return new Call({ - args: registry.parse(def.args) as Type, - returns: def.returns ? registry.parse(def.returns) : undefined, - throws: def.throws ? registry.parse(def.throws) : undefined, + args: inner.parse(def.args) as Type, + returns: def.returns ? inner.parse(def.returns) : undefined, + throws: def.throws ? inner.parse(def.throws) : undefined, get: def.get, set: def.set, docs: def.docs, + types: aliases, }); } -export function decodeInit(def: NonNullable, registry: Registry): Init { +export function decodeInit( + def: NonNullable, + scope: TypeScope, +): Init { return new Init({ - args: registry.parse(def.args) as Type, + args: scope.parse(def.args) as Type, run: def.run, docs: def.docs, }); diff --git a/packages/gin/src/type-scope.ts b/packages/gin/src/type-scope.ts new file mode 100644 index 00000000..a366ca75 --- /dev/null +++ b/packages/gin/src/type-scope.ts @@ -0,0 +1,101 @@ +import type { Registry } from './registry'; +import type { Type } from './type'; +import type { Expr } from './expr'; +import type { TypeDef, ExprDef } from './schema'; + +/** + * Type-name resolution scope. A tree of name → Type bindings rooted at + * the Registry. Used by `AliasType` to resolve `{name: 'X'}` lazily, + * and by `Registry.parse` to dispatch bare-name TypeDefs to AliasType + * when X is bound in a local scope. + * + * - The Registry is the root scope; it implements `TypeScope` directly. + * Its `lookup` walks `namedTypes` and built-in `classes`. + * - `LocalScope` wraps a parent scope with an overlay map. Used by + * `decodeCall` to scope `CallDef.types` aliases, by FnType to scope + * declared generics, etc. + * + * Distinct from: + * - `Scope` in `scope.ts` (runtime variable bindings — Value scope). + * - `Locals` in `analysis.ts` (`Map` for static + * variable-type analysis during validate / typeOf). + */ +export interface TypeScope { + /** Look up a type by name. Returns the bound Type if present in this + * scope or any parent scope; undefined if not found anywhere. */ + lookup(name: string): Type | undefined; + + /** Look up a type bound DIRECTLY in this scope's local layer. + * Does NOT walk parent. Used by `Registry.parseInner` to detect + * bare-name refs that must wrap as AliasType (so generic / alias + * substitution still works) rather than resolving eagerly through + * the registry. The Registry implementation returns undefined — + * registry hits aren't "local-above-root" bindings. */ + localLookup(name: string): Type | undefined; + + /** Parse a TypeDef in this scope. Convenience over + * `scope.registry.parse(def, scope)` — most type implementations' + * `from(def, scope)` recurse via `scope.parse(child)` without + * needing to thread the registry separately. */ + parse(def: unknown): Type; + + /** Parse an ExprDef in this scope. Mirrors `parse()` for the + * expression side. Used by Expr classes that recurse into nested + * expressions / lambdas / new defs. */ + parseExpr(def: unknown): Expr; + + /** The root Registry — every TypeScope can resolve to it via the + * parent chain. Use this to access builder methods (`registry.num()`) + * for fresh built-in instances. */ + readonly registry: Registry; + + /** Parent scope, or undefined for the root (Registry). */ + readonly parent?: TypeScope; +} + +/** + * A scope layer holding local name → Type bindings. Falls through to + * `parent.lookup` on miss. Construction order matters for sequential + * builds (later aliases referencing earlier ones); the caller is + * responsible for adding bindings in order if dependencies exist. + */ +export class LocalScope implements TypeScope { + readonly parent: TypeScope; + readonly registry: Registry; + private readonly local: Record; + + constructor(parent: TypeScope, local: Record = {}) { + this.parent = parent; + this.registry = parent.registry; + this.local = local; + } + + lookup(name: string): Type | undefined { + return this.local[name] ?? this.parent.lookup(name); + } + + localLookup(name: string): Type | undefined { + return this.local[name]; + } + + parse(def: unknown): Type { + return this.registry.parse(def as TypeDef, this); + } + + parseExpr(def: unknown): Expr { + return this.registry.parseExpr(def as ExprDef, this); + } + + /** Add a binding to this scope's local map. Used by sequential + * alias / generic build steps where each entry may reference + * earlier ones. */ + bind(name: string, type: Type): void { + this.local[name] = type; + } + + /** Return the names bound DIRECTLY in this scope (excluding parent). + * Used for diagnostics / rendering. */ + ownNames(): string[] { + return Object.keys(this.local); + } +} diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index ff769793..9919a691 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -1,15 +1,18 @@ import type { Registry } from './registry'; -import type { ExprDef, TypeDef, PathDef, PathStepDef } from './schema'; +import type { TypeScope } from './type-scope'; +import type { ExprDef, TypeDef, PathDef, PathStepDef, PropDef, GetSetDef, CallDef } from './schema'; import type { Expr } from './expr'; import { Value, val } from './value'; -import { substituteChildren } from './spec'; +import { Code, span as spanCode, jsonObject, jsonString, type JSONEntry } from './code'; import type { Node, CodeOptions } from './node'; import type { Engine } from './engine'; import { Problems } from './problem'; +import { walkValidate } from './analysis'; +import { ReturnSignal } from './flow-control'; import type { Scope } from './scope'; import type { JSONOf, RuntimeOf } from './json-type'; import { z } from 'zod'; -import type { SchemaOptions } from './node'; +import type { SchemaOptions, ValueSchemaOptions } from './node'; // ============================================================================ // RUNTIME SPEC SHAPES @@ -56,10 +59,21 @@ export class Prop { return x instanceof Prop ? x : new Prop(x); } + /** Serialize to PropDef JSON. Inverse of `decodeProp` in spec.ts. */ + toJSON(): PropDef { + return { + docs: this.docs, + type: this.type.toJSON(), + get: this.get, + default: this.default, + set: this.set, + }; + } + // ─── runtime ops (called by Path.walk) ───────────────────────────────── /** Read this prop on `self`: evaluate get Expr with {this, super?}, or - * fall back to direct object-field lookup. */ + * delegate to the parent type's `propGet` fallback. */ async read(self: Value, name: string, scope: Scope, engine: Engine): Promise { if (this.get) { const bindings: Record = { this: self }; @@ -67,24 +81,49 @@ export class Prop { if (sup) bindings.super = sup; return engine.evaluate(this.get, scope.child(bindings)); } - const raw = (self.raw as Record | null | undefined)?.[name]; - if (raw instanceof Value) return raw; - return val(this.type, raw); + return self.type.propGet(self, name, this.type); } - /** Write this prop on `self` with the given value. */ + /** + * Write this prop on `self` with the given value. + * + * 1. Prop declares an explicit `set` Expr → run it. + * 2. Prop is COMPUTED (`get` Expr present, no `set`, type not + * callable) → throw; there's no slot to hold a written value. + * 3. Otherwise → delegate to the parent type's `propSet`. Each + * Type subclass decides whether prop writes are meaningful + * against its raw shape (obj / iface / any: yes; num / text / + * list: no — `propSet` throws there). + * + * Method-typed props carry their body in `this.get`, so the + * `!this.type.call()` guard keeps method assignment from being + * mis-flagged as a computed-prop write. + */ async write(self: Value, name: string, value: Value, scope: Scope, engine: Engine): Promise { - if (!this.set) throw new Error(`path: prop '${name}' has no set expression`); - const bindings: Record = { this: self, value }; - const sup = self.type.propSuperFor(self, name, 'set', scope, engine); - if (sup) bindings.super = sup; - await engine.evaluate(this.set, scope.child(bindings)); + if (this.set) { + const bindings: Record = { this: self, value }; + const sup = self.type.propSuperFor(self, name, 'set', scope, engine); + if (sup) bindings.super = sup; + await engine.evaluate(this.set, scope.child(bindings)); + return; + } + if (this.get && !this.type.call()) { + throw new Error(`path: prop '${name}' is computed (has 'get', no 'set') — cannot assign to it`); + } + self.type.propSet(self, name, value); } /** * Invoke this prop as a method: runs get Expr with {this, args, super?, recurse}. * `fnType` is the effective (possibly generic-bound) Fn type used for the * recurse Value's type; defaults to this.type. + * + * When the prop has no `get` expression — the case for natively-installed + * globals like `fns.fetch` / `fns.llm` whose obj-field raw is a JS + * callable — fall back to invoking the raw value directly. This mirrors + * `Prop.read`'s direct-field fallback and the value-call branch in + * `Path.walk` (which is the path taken when the call follows a value + * read, not a method dispatch). */ async invokeMethod( self: Value, @@ -94,15 +133,41 @@ export class Prop { engine: Engine, fnType?: Type, ): Promise { - if (!this.get) throw new Error(`path: callable prop '${name}' has no get expression`); const effectiveType = fnType ?? this.type; + if (!this.get) { + const raw = (self.raw as Record | null | undefined)?.[name]; + const target = raw instanceof Value ? raw.raw : raw; + if (typeof target === 'function') { + return await (target as (a: Value) => Promise)(argsValue); + } + // Stored ExprDef (a lambda saved as JSON) — evaluate it to a callable + // Value first, then invoke. Mirrors how saved-fn globals dispatch. + if (target && typeof target === 'object' && 'kind' in (target as Record)) { + const lambdaValue = await engine.evaluate(target as ExprDef, scope); + if (typeof lambdaValue.raw === 'function') { + return await (lambdaValue.raw as (a: Value) => Promise)(argsValue); + } + } + throw new Error(`path: callable prop '${name}' has no get expression and raw is not a callable`); + } const getExpr = this.get; const callable = async (newArgs: Value): Promise => { const recurseValue = new Value(effectiveType, callable); const bindings: Record = { this: self, args: newArgs, recurse: recurseValue }; const sup = self.type.propSuperFor(self, name, 'get', scope, engine); if (sup) bindings.super = sup; - return engine.evaluate(getExpr, scope.child(bindings)); + // Catch `ReturnSignal` here so a saved fn body or method body + // can use `flow: 'return'` for early-exit. The body is the call + // boundary even though it's not literally wrapped in a + // LambdaExpr — same semantics as Lambda.evaluate's catch. + try { + return await engine.evaluate(getExpr, scope.child(bindings)); + } catch (sig) { + if (sig instanceof ReturnSignal) { + return sig.value ?? new Value(engine.registry.void(), undefined); + } + throw sig; + } }; return callable(argsValue); } @@ -147,6 +212,9 @@ export class GetSet { readonly get?: ExprDef; readonly set?: ExprDef; readonly loop?: ExprDef; + /** When true, `LoopExpr` re-evaluates `over` each iteration and + * exits on falsy `raw`. See `GetSetDef.loopDynamic`. */ + readonly loopDynamic?: boolean; readonly docs?: string; constructor(spec: { @@ -155,6 +223,7 @@ export class GetSet { get?: ExprDef; set?: ExprDef; loop?: ExprDef; + loopDynamic?: boolean; docs?: string; }) { this.key = spec.key; @@ -162,9 +231,23 @@ export class GetSet { this.get = spec.get; this.set = spec.set; this.loop = spec.loop; + this.loopDynamic = spec.loopDynamic; this.docs = spec.docs; } + /** Serialize to GetSetDef JSON. Inverse of `decodeGetSet` in spec.ts. */ + toJSON(): GetSetDef { + return { + docs: this.docs, + key: this.key.toJSON(), + value: this.value.toJSON(), + get: this.get, + set: this.set, + loop: this.loop, + loopDynamic: this.loopDynamic, + }; + } + /** Read this[key]: runs get Expr with {this, key, super?}. */ async indexRead(self: Value, keyValue: Value, scope: Scope, engine: Engine): Promise { if (!this.get) throw new Error(`path: type '${self.type.name}' has no index get`); @@ -186,6 +269,14 @@ export class GetSet { /** * Runtime Call — callable spec, with arg/return/throws Types resolved. + * + * `args` / `returns` / `throws` are parsed inside the call's local + * scope (a `LocalScope` carrying any `CallDef.types` aliases plus + * declared generics). Bare alias references inside those Types are + * `AliasType` instances that resolve via that scope; their `toJSON()` + * emits the bare-name form, which decodeCall then rebuilds against a + * freshly constructed LocalScope on round-trip. No source-form + * preservation needed — the structure is symmetric. */ export class Call { readonly args: Type; @@ -195,6 +286,11 @@ export class Call { readonly set?: ExprDef; readonly docs?: string; + /** Call-local type aliases declared on `CallDef.types`, parsed. + * Public so rendering (toCode / toCodeDefinition) can surface the + * alias header. Populated only when aliases were declared. */ + readonly types?: Record; + constructor(spec: { args: Type; returns?: Type; @@ -202,6 +298,7 @@ export class Call { get?: ExprDef; set?: ExprDef; docs?: string; + types?: Record; }) { this.args = spec.args; this.returns = spec.returns; @@ -209,6 +306,25 @@ export class Call { this.get = spec.get; this.set = spec.set; this.docs = spec.docs; + this.types = spec.types; + } + + /** Serialize to CallDef JSON. Inverse of `decodeCall` in spec.ts. */ + toJSON(): CallDef { + const types = this.types && Object.keys(this.types).length > 0 + ? Object.fromEntries( + Object.entries(this.types).map(([k, t]) => [k, t.toJSON()]), + ) + : undefined; + return { + docs: this.docs, + types, + args: this.args.toJSON(), + returns: this.returns?.toJSON(), + throws: this.throws?.toJSON(), + get: this.get, + set: this.set, + }; } } @@ -225,6 +341,15 @@ export class Init { this.run = spec.run; this.docs = spec.docs; } + + /** Serialize to InitDef JSON. Inverse of `decodeInit` in spec.ts. */ + toJSON(): NonNullable { + return { + docs: this.docs, + args: this.args.toJSON(), + run: this.run, + }; + } } // ============================================================================ @@ -268,7 +393,14 @@ export type Rnd = (min: number, max: number, whole: boolean) => number; */ export abstract class Type implements Node { constructor( - readonly registry: Registry, + /** + * Type-name resolution scope. Usually the Registry (root scope); + * Types parsed inside an FnType's generic-parameter scope or a + * `CallDef.types` alias scope hold a `LocalScope` instead, so that + * any `AliasType` captured in their tree can resolve through the + * same chain at use time. + */ + readonly scope: TypeScope, readonly options: O, /** * Generic parameter bindings (e.g. list stores V here). Empty for @@ -278,6 +410,10 @@ export abstract class Type implements Node { readonly generic: Record = {}, ) {} + /** Underlying Registry — shortcut for `this.scope.registry`, used + * by subclasses for builder access (`this.registry.num()` etc.). */ + get registry(): Registry { return this.scope.registry; } + /** Identifier of this type (e.g. 'num', 'text', 'list'). */ abstract readonly name: string; @@ -300,14 +436,20 @@ export abstract class Type implements Node { * solving `T` backwards through the refinement. Narrowing still works * — callers that need `Value.raw` typed simply rely on the Value's * constructor contract. + * + * Optional `scope` overlays an extra TypeScope on top of any + * AliasType resolutions inside this type — used by call-site + * generics (path step `generic: {R: numDef}`) so AliasType('R') + * resolves to num without rebuilding the type tree. */ - abstract valid(raw: unknown): boolean; + abstract valid(raw: unknown, scope?: TypeScope): boolean; /** * Parse a JSON-shape input into a Value of this type. * Throws if the input cannot be coerced to a valid raw value. + * `scope` propagates the call-site TypeScope (see `valid`). */ - abstract parse(json: unknown): Value; + abstract parse(json: unknown, scope?: TypeScope): Value; /** * Serialize a runtime raw value to its JSON shape. @@ -319,8 +461,9 @@ export abstract class Type implements Node { * Called by `Value.toJSON()` to build the outer `{type, value}` wire * envelope. For logical primitive output (no type info) callers can * walk `.raw` and read the underlying Value contents directly. + * `scope` propagates the call-site TypeScope (see `valid`). */ - abstract encode(raw: RuntimeOf): JSONOf; + abstract encode(raw: RuntimeOf, scope?: TypeScope): JSONOf; /** Default / zero raw value — used by { kind: 'new' } with no args. */ abstract create(): RuntimeOf; @@ -334,17 +477,18 @@ export abstract class Type implements Node { * Structural + (optional) strict compatibility check. * Concrete types implement this — the default impls below (accepts, * exact) compose it with pre-set option flags. + * `scope` propagates the call-site TypeScope (see `valid`). */ - abstract compatible(other: Type, opts?: CompatOptions): boolean; + abstract compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean; /** Strict: another instance of the same class must match structurally. */ - accepts(other: Type): boolean { - return this.compatible(other, { strict: true }); + accepts(other: Type, scope?: TypeScope): boolean { + return this.compatible(other, { strict: true }, scope); } /** Strict + exact: no wrapper unwrapping, no value-mode. */ - exact(other: Type): boolean { - return this.compatible(other, { strict: true, exact: true }); + exact(other: Type, scope?: TypeScope): boolean { + return this.compatible(other, { strict: true, exact: true }, scope); } /** True if this type accepts instances of other classes structurally. */ @@ -361,8 +505,9 @@ export abstract class Type implements Node { * `list`, pulling in every registry type compatible * with X. When `other` is a different class, the default fallback returns * `this` unchanged. + * `scope` propagates the call-site TypeScope (see `valid`). */ - like(_other: Type): Type { + like(_other: Type, _scope?: TypeScope): Type { return this; } @@ -388,8 +533,9 @@ export abstract class Type implements Node { */ abstract or(other: Type): Type; - /** Canonical form — collapse trivial wrappers. */ - simplify(): Type { + /** Canonical form — collapse trivial wrappers. AliasType uses + * `scope` to consult call-site bindings before its captured scope. */ + simplify(_scope?: TypeScope): Type { return this; } @@ -428,11 +574,21 @@ export abstract class Type implements Node { * The base defines universal props that every type inherits — `toAny` * is always available. Subclasses spread `super.props()` into their * return to pick these up. + * `scope` propagates the call-site TypeScope (see `valid`). */ - props(): Record { - return { + props(_scope?: TypeScope): Record { + // Universal props every type carries. + const base: Record = { toAny: this.registry.method({}, this.registry.any(), 'type.toAny'), }; + // Spread registry-augmentation props BEFORE returning. Subclasses + // override `props()` and prepend `super.props()` to their own — + // so augmentation lands BEFORE the subclass's intrinsic methods, + // i.e. intrinsic wins on name conflict (`num.add` can't be + // accidentally replaced by augmenting `num` with another `add`). + const aug = this.registry.augmentation(this.name); + if (!aug?.props) return base; + return { ...base, ...aug.props }; } /** Names of props defined universally on every Type (via base `props()`). @@ -451,24 +607,33 @@ export abstract class Type implements Node { return []; } - /** Effective GetSet — present iff this type supports [key] access. */ - get(): GetSet | undefined { - return undefined; + /** Effective GetSet — present iff this type supports [key] access. + * Falls back to a registry-augmentation when the type itself + * declares none. Augmentation NEVER overrides an intrinsic — it + * only fills the gap. (Subclasses that declare their own `get` + * override this method and don't consult augmentation.) */ + get(_scope?: TypeScope): GetSet | undefined { + return this.registry.augmentation(this.name)?.get; } - /** Effective Call — present iff this type is invocable. */ - call(): Call | undefined { - return undefined; + /** Effective Call — present iff this type is invocable. Augmented + * via `registry.augment(name, { call })` for types that aren't + * natively callable (e.g. making `timestamp` invocable). */ + call(_scope?: TypeScope): Call | undefined { + return this.registry.augmentation(this.name)?.call; } - /** Effective Init — present iff this type has a custom constructor. */ - init(): Init | undefined { - return undefined; + /** Effective Init — present iff this type has a custom constructor. + * Augmented via `registry.augment(name, { init })` for types that + * don't natively define one. When `init` is set on a type, `new T(args)` + * routes through it — see `NewExpr.evaluate`. */ + init(_scope?: TypeScope): Init | undefined { + return this.registry.augmentation(this.name)?.init; } /** Convenience over props() — single-name lookup, normalized to Prop. */ - prop(name: string): Prop | undefined { - const raw = this.props()[name]; + prop(name: string, scope?: TypeScope): Prop | undefined { + const raw = this.props(scope)[name]; return raw ? Prop.from(raw) : undefined; } @@ -478,51 +643,41 @@ export abstract class Type implements Node { * Resolve a single PathStep against this type, returning the sub-type * reached by that step (or undefined if the step doesn't apply here). * Concrete types with positional semantics (Tuple) may override. + * `scope` propagates the call-site TypeScope (see `valid`). */ - follow(step: PathStepDef): Type | undefined { + follow(step: PathStepDef, scope?: TypeScope): Type | undefined { if ('prop' in step) { - return this.prop(step.prop)?.type; + return this.prop(step.prop, scope)?.type; } if ('args' in step) { - return this.call()?.returns; + return this.call(scope)?.returns; } if ('key' in step) { - return this.get()?.value; + return this.get(scope)?.value; } return undefined; } /** Fold follow() over a whole Path. */ - at(path: PathDef): Type | undefined { + at(path: PathDef, scope?: TypeScope): Type | undefined { let current: Type | undefined = this; for (const step of path) { if (!current) return undefined; - current = current.follow(step); + current = current.follow(step, scope); } return current; } - // ─── GENERIC BINDING ───────────────────────────────────────────────────── - - /** - * Substitute generic placeholders in this type using the given bindings. - * Delegates to `substitute(bindings)` — each Type class chooses its own - * substitution semantics polymorphically. - */ - bind(bindings: Record): Type { - if (Object.keys(bindings).length === 0) return this; - return this.substitute(bindings); - } - - /** - * Default substitution: walk the common child-type fields via the - * JSON-shape helper. GenericType overrides to return its binding. - * Other types with no generic placeholders just return this. - */ - substitute(bindings: Record): Type { - if (Object.keys(bindings).length === 0) return this; - return this.registry.parse(substituteChildren(this.toJSON(), bindings, this.registry)); - } + // ─── GENERIC RESOLUTION (scope-based; no bind/substitute) ─────────────── + // + // Generic placeholders are AliasType instances whose `scope` chain + // includes the binding (see `FnType.from`'s LocalScope, decodeCall's + // alias map, etc.). To specialize a generic at a call site, callers + // pass an extra `scope: TypeScope` (a LocalScope layered on top of + // the captured scope, with call-site bindings) into the methods + // that resolve types — `parse`, `valid`, `compatible`, `props`, + // `call`, etc. AliasType.resolve consults `extra` first, falling + // back to its captured scope. No type tree is rebuilt. // ─── SCHEMA ROUND-TRIP ─────────────────────────────────────────────────── @@ -556,7 +711,7 @@ export abstract class Type implements Node { * - `includeDocs: 'type' | 'all'` — attach `.describe(this.docs)` if * set. 'all' also describes individual props / fields / get / call. */ - abstract toValueSchema(opts?: SchemaOptions): z.ZodTypeAny; + abstract toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny; /** * Produce a Zod schema for the VALUE side of a `{ kind: 'new' }` Expr of @@ -567,9 +722,21 @@ export abstract class Type implements Node { * the Zod shape. So `new obj { x: text, y: num }` accepts * `{ x: , y: }`. * - * Default = `toValueSchema(opts)`; composites override. + * Default behaviour: + * - When the type defines `init()` (a constructor), the value + * slot IS the init's args obj. `new (args)` literally calls + * `init.run` with `args` parsed against `init.args`, so the + * schema the LLM sees should be that args type. + * - Otherwise fall through to `toValueSchema(opts)`. + * + * Composites still override (list / map / obj / tuple / typ / ... + * have richer Expr-slot shapes that don't fit the init mould). */ toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + const init = this.init(); + if (init) { + return this.describeType(init.args.toValueSchema(opts), opts, 'NewValue_'); + } return this.toValueSchema(opts); } @@ -584,7 +751,7 @@ export abstract class Type implements Node { */ protected describeType( schema: z.ZodTypeAny, - opts?: SchemaOptions, + opts?: ValueSchemaOptions, aidPrefix: 'Value_' | 'NewValue_' | null = 'Value_', ): z.ZodTypeAny { const mode = opts?.includeDocs ?? 'none'; @@ -649,21 +816,67 @@ export abstract class Type implements Node { * * Accepts optional Registry + CodeOptions for uniformity with Expr.toCode; * most Types ignore both (a type is always a single expression). + * + * Subclasses MUST implement this; the base `toGinCode` reaches into + * the subclass's `toCode` to produce a coarse-span fallback. */ abstract toCode(registry?: Registry, options?: CodeOptions): string; /** - * Inline `/* docs * /` prefix for `toCode` output when this type has docs. - * Mirrors `Expr.commentPrefix`. Subclasses that want docs rendered call - * `this.docsPrefix() + ` from their `toCode` implementation. + * Render as gin's TS-pseudocode form as a structured `Code` value + * carrying spans. Default: wrap the legacy `toCode` output in a + * single coarse span tagged with `path` + `type: this`. Composite + * types (obj/list/map/tuple/fn/iface) override to thread child + * paths through. */ - protected docsPrefix(): string { - return this.docs ? `/* ${this.docs} */ ` : ''; + toGinCode( + registry?: Registry, + options?: CodeOptions, + path: ReadonlyArray = [], + ): Code { + const text = this.toCode(registry, options); + return spanCode(text, { path, type: this }); + } + + /** + * Render as the JSON form of `toJSON()` with spans aligned to JSON + * positions. Walks the standard `TypeDef` structure (props / get / + * call / init / generic / options / constraint / etc.) so each + * sub-slot — and every embedded ExprDef inside Prop.get, + * GetSet.get/set/loop, Call.get/set, Init.run, constraint — gets + * its own span, with nested types recursing into their own + * `toJSONCode`. This makes `formatProblem` / `formatProblems` able + * to underline the precise offending range inside a type + * definition (the same way they already do inside Expr trees). + * + * Subclasses can override for custom shapes; otherwise this default + * suffices for every TypeDef-shaped JSON. + */ + toJSONCode( + path: ReadonlyArray = [], + indent: number = 2, + level: number = 0, + ): Code { + return renderTypeDefJSONCode(this.toJSON(), this.registry, path, indent, level, this); + } + + /** + * Inline `/* docs * /` prefix for `toCode` output. Always returns empty + * by default — type docs would otherwise repeat at every reference, + * burying real signal in noise (every `args.x: T` annotation, every + * `new T {...}`, every type parameter would carry the full prose). Docs + * stay on the type definition (rendered as a `// docs` header by + * `toCodeDefinition`) where they describe the type ONCE. The hook + * exists so a subclass could opt back in with policy if needed; today + * none do. + */ + protected docsPrefix(_options?: CodeOptions): string { + return ''; } /** ` extends ` clause on the `type ` header — empty for * built-in classes; Extension overrides to show its base type. */ - protected extendsClause(): string { + protected extendsClause(_options?: CodeOptions): string { return ''; } @@ -694,32 +907,42 @@ export abstract class Type implements Node { * [key: "title" | "done" | "due"]: string | boolean | Date | undefined * } */ - toCodeDefinition(): string { + toCodeDefinition(options?: CodeOptions): string { const lines: string[] = []; + const includeComments = options?.includeComments !== false; + + // Call-local type aliases — rendered first so they read like + // class-level type-alias declarations and can be referenced when + // reading the constructor / call signature lines below. + const call = this.definitionCall(); + if (call?.types) { + for (const [name, t] of Object.entries(call.types)) { + lines.push(` type ${name} = ${t.toCode(undefined, options)};`); + } + } // Constructor — rendered first so the shape reads like a class. const init = this.definitionInit(); if (init) { - if (init.docs) lines.push(` // ${init.docs}`); - lines.push(` new(${formatParams(init.args)})`); + if (init.docs && includeComments) lines.push(` // ${init.docs}`); + lines.push(` new(${formatParams(init.args, options)})`); } // Call signature (`fn` / iface with call / Extension with call). - const call = this.definitionCall(); if (call) { - const ret = call.returns?.toCode() ?? 'void'; - lines.push(` (${formatParams(call.args)}): ${ret}`); + const ret = call.returns?.toCode(undefined, options) ?? 'void'; + lines.push(` (${formatParams(call.args, options)}): ${ret}`); } // Index signature. const gs = this.definitionGet(); - if (gs) lines.push(` [key: ${gs.key.toCode()}]: ${gs.value.toCode()}`); + if (gs) lines.push(` [key: ${gs.key.toCode(undefined, options)}]: ${gs.value.toCode(undefined, options)}`); // Fields + methods. const ownGenerics = new Set(Object.keys(this.generic)); for (const [name, raw] of Object.entries(this.definitionProps())) { const prop = raw instanceof Prop ? raw : Prop.from(raw); - if (prop.docs) lines.push(` // ${prop.docs}`); + if (prop.docs && includeComments) lines.push(` // ${prop.docs}`); const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; const opt = optional ? '?' : ''; @@ -733,21 +956,21 @@ export abstract class Type implements Node { && !t.get() && nonUniversalKeys.length === 0; if (pureCallable) { - const ret = propCall!.returns?.toCode() ?? 'void'; + const ret = propCall!.returns?.toCode(undefined, options) ?? 'void'; // Method-level generics — declared on the fn's `.generic`, filtered // to those NOT inherited from the outer type's own generics. const methodGen = Object.fromEntries( Object.entries(t.generic).filter(([k]) => !ownGenerics.has(k)), ); - const gParams = renderGenerics(methodGen); - lines.push(` ${name}${opt}${gParams}(${formatParams(propCall!.args)}): ${ret}`); + const gParams = renderGenerics(methodGen, options); + lines.push(` ${name}${opt}${gParams}(${formatParams(propCall!.args, options)}): ${ret}`); } else { - lines.push(` ${name}${opt}: ${t.toCode()}`); + lines.push(` ${name}${opt}: ${t.toCode(undefined, options)}`); } } - const docLine = this.docs ? `// ${this.docs}\n` : ''; - const header = `${docLine}type ${this.name}${renderGenerics(this.generic)}${this.extendsClause()}`; + const docLine = this.docs && includeComments ? `// ${this.docs}\n` : ''; + const header = `${docLine}type ${this.name}${renderGenerics(this.generic, options)}${this.extendsClause(options)}`; return lines.length === 0 ? `${header} {}` : `${header} {\n${lines.join('\n')}\n}`; } @@ -780,20 +1003,99 @@ export abstract class Type implements Node { return undefined; } + // ─── STRUCTURAL PROP ACCESS (default fallbacks) ───────────────────────── + // + // `Prop.read` and `Prop.write` delegate to these when the prop has no + // explicit `get` / `set` Expr. Each Type subclass decides whether prop + // access against its raw shape is meaningful: + // + // - obj / iface / any / extension: yes — raw is structurally a + // `Record`. The default implementations below already + // do the right thing for those. + // - num / text / bool / list / map / fn / tuple / enum / literal / + // date / duration / timestamp / color / void / null: no — raw + // isn't an object. `propGet` returns `Value(propType, undefined)` + // (which gracefully turns into undefined / null reads); `propSet` + // throws with a clear message. + // + // Subclasses can override either method to enforce stricter semantics + // (e.g. an immutable type rejecting `propSet`, or a typed accessor + // that wraps raw values according to a per-slot schema). + + /** + * Read the structural slot at `name` from `self`. The `propType` is + * the prop's declared type, used to wrap a non-Value raw into a + * typed Value. + * + * Default behaviour: treat `self.raw` as object-shaped, look up + * `raw[name]`, and return either the stored Value (already typed) + * or wrap a raw scalar into `val(propType, raw)`. For raws that + * aren't object-shaped this returns `val(propType, undefined)` — + * which is what `Prop.read` would have produced before. Types + * with non-object raws should generally not have props declared + * against them, so this branch is rarely exercised. + */ + propGet(self: Value, name: string, propType: Type): Value { + const raw = self.raw && typeof self.raw === 'object' + ? (self.raw as Record)[name] + : undefined; + if (raw instanceof Value) return raw; + return val(propType, raw); + } + + /** + * Write `value` into the structural slot at `name` on `self`. + * + * Default behaviour: assign directly into `self.raw[name]` for + * object-shaped raws (works for `obj`, `iface`, `any`, plus any + * Value backed by a plain JS object — including the `vars` proxy, + * which observes the assignment via its `set` trap and marks the + * slot dirty for persistence). Types with non-object raws throw + * with a clear message — there's nowhere to put the value. + */ + propSet(self: Value, name: string, value: Value): void { + if (self.raw && typeof self.raw === 'object') { + (self.raw as Record)[name] = value; + return; + } + throw new Error(`type '${this.name}': cannot set prop '${name}' — value's raw is not an object`); + } + // ─── VALIDATE ──────────────────────────────────────────────────────────── /** - * Walk this Type collecting structural problems (round-trip encode/parse - * as a minimum sanity check). Types may override to add deeper checks. - * Matches the Node interface shared with Expr. + * Walk this Type collecting structural problems: + * + * 1. Round-trip encode/parse — catches malformed serialization. + * 2. Walk the type's full surface (`props()`, `get()`, `call()`, + * `init()`) and validate every embedded Expr — method bodies, + * getters/setters, indexed-access handlers, init runs. + * + * Each embedded Expr is validated with the runtime scope it'll + * actually see (`this`, `args`, `recurse`, `super`, `key`, `value`) + * pre-bound, then its inferred type is compared against the slot's + * declared shape (`Prop.type`, `Call.returns`, `GetSet.value`, etc.) + * — mismatches surface as warnings. + * + * Most slots on built-in types hold a `{kind:'native', id:'…'}` + * marker, which validates trivially (NativeExpr just checks impl + * registration). User-supplied bodies on Extensions / + * `registry.augment(...)` go through the full walker. + * + * `engine.validate(programExpr)` does NOT call this — programs + * are scoped to their own tree. To catch issues in user-supplied + * type surface, call `type.validate(engine)` directly or + * `registry.validate(engine)` to sweep every named type + + * augmentation. */ - validate(_engine: Engine): Problems { + validate(engine: Engine): Problems { const p = new Problems(); try { this.registry.parse(this.toJSON()); } catch (err) { p.error('type.invalid', (err as Error).message); } + validateTypeSurface(this, engine, p); return p; } @@ -808,15 +1110,31 @@ export abstract class Type implements Node { } /** - * Serialize a type's `options` as gin's `{key=value, …}` suffix. Empty / - * all-undefined options render as the empty string, so primitives without - * narrowing (`num`, `text`) stay bare. Values use JSON encoding for - * strings / null / arrays / objects; numbers and booleans render literal. + * Serialize a type's `options` as gin's `{key=value, …}` suffix. Empty + * / all-undefined options render as the empty string, so primitives + * without narrowing (`num`, `text`) stay bare. + * + * Skips noise that adds no information: + * - undefined values + * - empty strings (`prefix=""`, `suffix=""`) + * - entries equal to the optional `defaults` map (per-type + * "uninteresting default" — e.g. `minPrecision=1` on num is rarely + * worth surfacing; if equal to the default, don't render it) + * + * Values use JSON encoding for strings / null / arrays / objects; + * numbers and booleans render as literals. */ -export function optionsCode(opts: object | undefined | null): string { +export function optionsCode( + opts: object | undefined | null, + defaults?: Record, +): string { if (!opts) return ''; - const entries = Object.entries(opts as Record) - .filter(([, v]) => v !== undefined); + const entries = Object.entries(opts as Record).filter(([k, v]) => { + if (v === undefined) return false; + if (v === '') return false; + if (defaults && k in defaults && deepEqual(defaults[k], v)) return false; + return true; + }); if (entries.length === 0) return ''; const parts = entries.map(([k, v]) => { const encoded = typeof v === 'string' @@ -828,24 +1146,53 @@ export function optionsCode(opts: object | undefined | null): string { : JSON.stringify(v); return `${k}=${encoded}`; }); - return `{${parts.join(', ')}}`; + return `{${joinAuto(parts)}}`; +} + +/** Cheap deep-equality for `optionsCode`'s defaults skip. Stable JSON + * stringify is good enough — option values are small primitives / + * arrays / records, not class instances or functions. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + try { return JSON.stringify(a) === JSON.stringify(b); } + catch { return false; } } /** - * Render a type's generic-parameter map as ``. `T` when bound - * is `any` (unconstrained) or a self-referencing GenericType placeholder, - * `T: code` otherwise. Shared by type headers and fn signatures. + * Render a Call's `types` (call-local type aliases) as a header block + * `{a: ; b: }` immediately after the generic params and + * before the parameter list. Empty / missing map → empty string. */ -export function renderGenerics(generic: Record): string { +export function renderCallTypes( + types: Record | undefined, + options?: CodeOptions, +): string { + if (!types) return ''; + const keys = Object.keys(types); + if (keys.length === 0) return ''; + const parts = keys.map((k) => `${k}: ${types[k]!.toCode(undefined, options)}`); + return `{${joinAuto(parts, { sep: '; ' })}}`; +} + +/** + * Render a type's generic-parameter map as ``. `T` when + * bound is `any` (unconstrained) or a self-referencing AliasType + * placeholder, `T: code` otherwise. Shared by type headers and fn + * signatures. + */ +export function renderGenerics( + generic: Record, + options?: CodeOptions, +): string { const keys = Object.keys(generic); if (keys.length === 0) return ''; const parts = keys.map((k) => { const t = generic[k]!; - const selfRef = t.name === 'generic' + const selfRef = t.name === 'alias' && (t.options as { name?: string } | undefined)?.name === k; - return t.name === 'any' || selfRef ? k : `${k}: ${t.toCode()}`; + return t.name === 'any' || selfRef ? k : `${k}: ${t.toCode(undefined, options)}`; }); - return `<${parts.join(', ')}>`; + return `<${joinAuto(parts)}>`; } /** @@ -854,16 +1201,520 @@ export function renderGenerics(generic: Record): string { * type for args, so duck-typing on `.fields` covers the common case; * anything else falls back to a single `args: ` param. */ -export function formatParams(args: Type): string { +export function formatParams(args: Type, options?: CodeOptions): string { const fields = (args as unknown as { fields?: Record }).fields; if (!fields) return args.name === 'void' || args.name === 'any' ? '' - : `args: ${args.toCode()}`; + : `args: ${args.toCode(undefined, options)}`; const parts = Object.entries(fields).map(([name, prop]) => { const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; - const docs = prop.docs ? `/* ${prop.docs} */ ` : ''; - return `${docs}${name}${optional ? '?' : ''}: ${t.toCode()}`; + const docs = prop.docs && options?.includeComments !== false ? `/* ${prop.docs} */ ` : ''; + return `${docs}${name}${optional ? '?' : ''}: ${t.toCode(undefined, options)}`; }); - return parts.join(', '); + return joinAuto(parts); +} + +/** + * Delimiter-join with automatic wrapping for long content. Used by + * every comma- or semicolon-delimited renderer (params, call args, + * new-list / new-obj literals, call.types alias headers, …) so they + * stay readable at any depth. + * + * Compact form: `a b c` — when every item is short and + * single-line. Default separator is `, ` for comma-lists; pass `'; '` + * for semicolon-lists (e.g. `call.types` alias headers). + * + * Wrapped form: leading `\n`, each item indented by two spaces and + * followed by `<\n>`, trailing `\n` before the caller's + * closing delimiter: + * + * `(\n a: very-long-type,\n b: another-long-type\n)` + * + * Triggers when ANY of: + * - an item exceeds `threshold` characters (default 32), + * - an item itself contains a newline (already wrapped deeper), + * - the compact joined form would exceed `totalThreshold` characters + * (default 80) — keeps long-but-numerous arg lists from rendering + * as one mega-line just because each item is individually short. + * 80 leaves headroom for the caller's surrounding delimiters / + * indent so finished lines tend to land near a 100-char target. + * Already-multi-line items get their newlines indented so nesting + * doesn't lose alignment. + */ +export function joinAuto( + items: string[], + opts: { sep?: string; threshold?: number; totalThreshold?: number } = {}, +): string { + if (items.length === 0) return ''; + const sep = opts.sep ?? ', '; + const threshold = opts.threshold ?? 32; + const totalThreshold = opts.totalThreshold ?? 80; + const compact = items.join(sep); + const wrap = compact.length > totalThreshold + || items.some((i) => i.length > threshold || i.includes('\n')); + if (!wrap) return compact; + // Strip trailing whitespace from the separator so the wrapped form + // emits e.g. `,\n` (not `, \n`) — newline already does the spacing. + const wrapSep = sep.replace(/\s+$/, ''); + const indented = items.map((i) => ' ' + i.replace(/\n/g, '\n ')); + return `\n${indented.join(`${wrapSep}\n`)}\n`; +} + +// ============================================================================ +// SURFACE VALIDATION (Type.validate's deep walk) +// ============================================================================ + +/** + * Walk a Type's surface — props, get/set, call, init — and validate every + * embedded ExprDef. Each slot's body is parsed via the registry, then run + * through `walkValidate` with the runtime scope it'll see at evaluation + * time (`this`, `args`, `recurse`, `super`, `key`, `value`). Inferred + * body types are compared against the slot's declared shape; mismatches + * surface as warnings. + * + * Built-in slots usually hold `{kind:'native', id:'…'}` which validates + * trivially. Real Expr bodies attached via `registry.extend()` / + * `registry.augment()` get the full walk. + */ +function validateTypeSurface(type: Type, engine: Engine, p: Problems): void { + const reg = type.registry; + const ctx = { inLoop: false, inLambda: false } as const; + + // ─── Props (named methods + getters + defaults) ──────────────────────── + const props = type.props(); + for (const [name, raw] of Object.entries(props)) { + const prop = raw instanceof Prop ? raw : Prop.from(raw); + p.at(['props', name], () => { + validateEmbedded(prop.get, propGetScope(prop, type, reg), declaredOf(prop), 'get', engine, p, ctx); + validateEmbedded(prop.set, propSetScope(prop, type, reg), reg.void(), 'set', engine, p, ctx); + // Default has no `this` — it builds a fresh value of the prop's type. + validateEmbedded(prop.default, new Map(), prop.type, 'default', engine, p, ctx); + }); + } + + // ─── GetSet (indexed-access spec) ────────────────────────────────────── + const gs = type.get(); + if (gs) { + p.at('get', () => { + const baseGetScope = new Map([['this', type], ['key', gs.key]]); + const baseSetScope = new Map([['this', type], ['key', gs.key], ['value', gs.value]]); + validateEmbedded(gs.get, baseGetScope, gs.value, 'get', engine, p, ctx); + validateEmbedded(gs.set, baseSetScope, reg.void(), 'set', engine, p, ctx); + // Loop body — `key` and `value` are the iteration variables yielded + // by the loop driver. Validate without an output-type expectation + // (loop drives via `yield` flow signals; its return type is not + // directly checked here). + validateEmbedded(gs.loop, baseGetScope, undefined, 'loop', engine, p, ctx); + }); + } + + // ─── Call (invocable spec) ───────────────────────────────────────────── + const call = type.call(); + if (call) { + p.at('call', () => { + // call.get is the method body — runs with this/args/recurse bound. + const callScope = new Map([ + ['this', type], + ['args', call.args], + ['recurse', reg.fn(call.args, call.returns ?? reg.any())], + ]); + validateEmbedded(call.get, callScope, call.returns, 'get', engine, p, { ...ctx, inLambda: true }); + const callSetScope = new Map([ + ['this', type], ['args', call.args], ['value', call.returns ?? reg.any()], + ['recurse', reg.fn(call.args, call.returns ?? reg.any())], + ]); + validateEmbedded(call.set, callSetScope, reg.void(), 'set', engine, p, { ...ctx, inLambda: true }); + }); + } + + // ─── Init (constructor) ──────────────────────────────────────────────── + const init = type.init(); + if (init) { + p.at('init', () => { + const initScope = new Map([['this', type], ['args', init.args]]); + // Init body returns a representation of `this` — most often it + // mutates in place and returns void/undefined. We don't enforce a + // specific return shape here. + validateEmbedded(init.run, initScope, undefined, 'run', engine, p, ctx); + }); + } + + // ─── Constraint (Extension's runtime invariant) ──────────────────────── + for (const constraint of type.constraints()) { + p.at('constraint', () => { + const constraintScope = new Map([['this', type]]); + const inferred = walkValidate(engine, constraint, constraintScope, p, ctx); + const boolT = reg.bool(); + if (inferred.name !== 'any' && !boolT.compatible(inferred)) { + p.warn('type.surface.return-type', + `constraint must return bool, got '${inferred.name}'`); + } + }); + } +} + +/** Build the scope for a Prop's `get` slot. Method-typed props see + * `args` + `recurse`; plain getters just see `this`. */ +function propGetScope(prop: Prop, owner: Type, reg: Registry): Map { + const m = new Map([['this', owner]]); + const c = prop.type.call?.(); + if (c) { + m.set('args', c.args); + m.set('recurse', reg.fn(c.args, c.returns ?? reg.any())); + } + return m; +} + +/** Build the scope for a Prop's `set` slot. Always carries `this` and + * the incoming `value`. */ +function propSetScope(prop: Prop, owner: Type, _reg: Registry): Map { + return new Map([['this', owner], ['value', prop.type]]); +} + +/** Resolve the declared output type for a Prop's `get` slot. Callable + * props (methods) effectively return `call.returns`; plain props + * return the prop's declared `type`. */ +function declaredOf(prop: Prop): Type | undefined { + const c = prop.type.call?.(); + if (c) return c.returns; + return prop.type; +} + +/** Validate a single embedded ExprDef. Skips when the slot is empty. + * Parses through the registry, runs the validator with the supplied + * Locals scope, and (when an `expected` Type is given) warns if the + * inferred body type isn't compatible with what the slot declares. + * + * `any` is treated as a universal escape hatch — when the body's + * inferred type is `any` (the default for unattributed + * `{kind:'native', id:'…'}` markers), trust the slot's declared + * return type. The native impl is responsible for producing a value + * that matches the declaration; the validator can't see through the + * TS function. */ +function validateEmbedded( + expr: ExprDef | undefined, + scope: Map, + expected: Type | undefined, + slot: string, + engine: Engine, + p: Problems, + ctx: { inLoop: boolean; inLambda: boolean }, +): void { + if (!expr) return; + // Wrap the body's walk under `slot` so problems carry the slot path + // (`['props', name, 'get', …]` rather than `['props', name, …]`), + // making them addressable to the type's JSON form via `spanFor`. + p.at(slot, () => { + const inferred = walkValidate(engine, expr, scope, p, ctx); + if (expected && inferred.name !== 'any' && !expected.compatible(inferred)) { + p.warn('type.surface.return-type', + `${slot} body returns '${inferred.name}', not compatible with declared '${expected.name}'`); + } + }); +} + +// ============================================================================ +// JSON CODE (Type.toJSONCode's structural walk) +// ============================================================================ + +/** + * Render a TypeDef as Code with fine-grained spans tied to validator + * paths. Each known structural slot (`name`, `extends`, `docs`, + * `options`, `generic`, `props`, `get`, `set`, `call`, `init`, + * `constraint`, `satisfies`) becomes its own JSON entry whose value + * is rendered with span(s) aligned to its slot path. Recurses into + * nested TypeDefs and parses embedded ExprDefs through the registry + * so `Expr.toJSONCode` produces the inner spans. + * + * Used by `Type.toJSONCode` so `formatProblem(typeJsonCode, problem)` + * can underline precisely inside a type definition the way it does + * inside an Expr tree. + */ +function renderTypeDefJSONCode( + def: TypeDef, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, + type: Type | undefined, +): Code { + const childLevel = level + 1; + const entries: JSONEntry[] = []; + + if (def.name !== undefined) { + entries.push({ key: 'name', value: jsonString(def.name) }); + } + if (def.extends !== undefined) { + entries.push({ key: 'extends', value: jsonString(def.extends) }); + } + if (def.satisfies !== undefined && def.satisfies.length > 0) { + entries.push({ + key: 'satisfies', + value: rawJSON(def.satisfies, [...path, 'satisfies'], indent, childLevel), + }); + } + if (def.docs !== undefined) { + entries.push({ key: 'docs', value: jsonString(def.docs) }); + } + if (def.options !== undefined && Object.keys(def.options as object).length > 0) { + // Options are a free-form per-type record (no embedded Exprs in + // any built-in's options); render as plain JSON with a span. + entries.push({ + key: 'options', + value: rawJSON(def.options, [...path, 'options'], indent, childLevel), + }); + } + if (def.generic !== undefined && Object.keys(def.generic).length > 0) { + entries.push({ + key: 'generic', + value: renderTypeMapJSON(def.generic, registry, [...path, 'generic'], indent, childLevel), + }); + } + if (def.props !== undefined && Object.keys(def.props).length > 0) { + entries.push({ + key: 'props', + value: renderPropsMapJSON(def.props, registry, [...path, 'props'], indent, childLevel), + }); + } + if (def.get !== undefined) { + entries.push({ + key: 'get', + value: renderGetSetJSON(def.get, registry, [...path, 'get'], indent, childLevel), + }); + } + if (def.call !== undefined) { + entries.push({ + key: 'call', + value: renderCallJSON(def.call, registry, [...path, 'call'], indent, childLevel), + }); + } + if (def.init !== undefined) { + entries.push({ + key: 'init', + value: renderInitJSON(def.init, registry, [...path, 'init'], indent, childLevel), + }); + } + if (def.constraint !== undefined) { + entries.push({ + key: 'constraint', + value: renderEmbeddedExprJSON(def.constraint, registry, [...path, 'constraint'], indent, childLevel), + }); + } + + return jsonObject(entries, { path, type }, level, indent); +} + +/** Render `{name: TypeDef, …}` map (used by `generic` and `call.types`). */ +function renderTypeMapJSON( + map: Record, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + const entries: JSONEntry[] = Object.entries(map).map(([name, def]) => ({ + key: name, + value: renderTypeDefJSONCode(def, registry, [...path, name], indent, level + 1, undefined), + })); + return jsonObject(entries, { path }, level, indent); +} + +/** Render `{name: PropDef, …}` map under `props`. */ +function renderPropsMapJSON( + props: Record, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + const entries: JSONEntry[] = Object.entries(props).map(([name, prop]) => ({ + key: name, + value: renderPropDefJSON(prop, registry, [...path, name], indent, level + 1), + })); + return jsonObject(entries, { path }, level, indent); +} + +/** Render a single `PropDef = { docs?, type, get?, set?, default? }`. */ +function renderPropDefJSON( + prop: PropDef, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + const childLevel = level + 1; + const entries: JSONEntry[] = []; + if (prop.docs !== undefined) entries.push({ key: 'docs', value: jsonString(prop.docs) }); + entries.push({ + key: 'type', + value: renderTypeDefJSONCode(prop.type, registry, [...path, 'type'], indent, childLevel, undefined), + }); + if (prop.get !== undefined) { + entries.push({ + key: 'get', + value: renderEmbeddedExprJSON(prop.get, registry, [...path, 'get'], indent, childLevel), + }); + } + if (prop.set !== undefined) { + entries.push({ + key: 'set', + value: renderEmbeddedExprJSON(prop.set, registry, [...path, 'set'], indent, childLevel), + }); + } + if (prop.default !== undefined) { + entries.push({ + key: 'default', + value: renderEmbeddedExprJSON(prop.default, registry, [...path, 'default'], indent, childLevel), + }); + } + return jsonObject(entries, { path }, level, indent); +} + +function renderGetSetJSON( + gs: GetSetDef, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + const childLevel = level + 1; + const entries: JSONEntry[] = []; + if (gs.docs !== undefined) entries.push({ key: 'docs', value: jsonString(gs.docs) }); + entries.push({ + key: 'key', + value: renderTypeDefJSONCode(gs.key, registry, [...path, 'key'], indent, childLevel, undefined), + }); + entries.push({ + key: 'value', + value: renderTypeDefJSONCode(gs.value, registry, [...path, 'value'], indent, childLevel, undefined), + }); + if (gs.get !== undefined) { + entries.push({ + key: 'get', + value: renderEmbeddedExprJSON(gs.get, registry, [...path, 'get'], indent, childLevel), + }); + } + if (gs.set !== undefined) { + entries.push({ + key: 'set', + value: renderEmbeddedExprJSON(gs.set, registry, [...path, 'set'], indent, childLevel), + }); + } + if (gs.loop !== undefined) { + entries.push({ + key: 'loop', + value: renderEmbeddedExprJSON(gs.loop, registry, [...path, 'loop'], indent, childLevel), + }); + } + if (gs.loopDynamic !== undefined) { + entries.push({ key: 'loopDynamic', value: String(gs.loopDynamic) }); + } + return jsonObject(entries, { path }, level, indent); +} + +function renderCallJSON( + call: CallDef, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + const childLevel = level + 1; + const entries: JSONEntry[] = []; + if (call.docs !== undefined) entries.push({ key: 'docs', value: jsonString(call.docs) }); + if (call.types !== undefined && Object.keys(call.types).length > 0) { + entries.push({ + key: 'types', + value: renderTypeMapJSON(call.types, registry, [...path, 'types'], indent, childLevel), + }); + } + entries.push({ + key: 'args', + value: renderTypeDefJSONCode(call.args, registry, [...path, 'args'], indent, childLevel, undefined), + }); + if (call.returns !== undefined) { + entries.push({ + key: 'returns', + value: renderTypeDefJSONCode(call.returns, registry, [...path, 'returns'], indent, childLevel, undefined), + }); + } + if (call.throws !== undefined) { + entries.push({ + key: 'throws', + value: renderTypeDefJSONCode(call.throws, registry, [...path, 'throws'], indent, childLevel, undefined), + }); + } + if (call.get !== undefined) { + entries.push({ + key: 'get', + value: renderEmbeddedExprJSON(call.get, registry, [...path, 'get'], indent, childLevel), + }); + } + if (call.set !== undefined) { + entries.push({ + key: 'set', + value: renderEmbeddedExprJSON(call.set, registry, [...path, 'set'], indent, childLevel), + }); + } + return jsonObject(entries, { path }, level, indent); +} + +function renderInitJSON( + init: NonNullable, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + const childLevel = level + 1; + const entries: JSONEntry[] = []; + if (init.docs !== undefined) entries.push({ key: 'docs', value: jsonString(init.docs) }); + entries.push({ + key: 'args', + value: renderTypeDefJSONCode(init.args, registry, [...path, 'args'], indent, childLevel, undefined), + }); + entries.push({ + key: 'run', + value: renderEmbeddedExprJSON(init.run, registry, [...path, 'run'], indent, childLevel), + }); + return jsonObject(entries, { path }, level, indent); +} + +/** Render an embedded ExprDef. Parses through the registry and + * delegates to the parsed Expr's `toJSONCode` so the inner span + * structure mirrors what `engine.toJSONCode(expr)` produces at the + * top level. Falls back to a plain-JSON span when parse fails (e.g. + * malformed kind, unknown class) — better to render something than + * to throw mid-render. */ +function renderEmbeddedExprJSON( + expr: ExprDef, + registry: Registry, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + try { + const parsed = registry.parseExpr(expr); + return parsed.toJSONCode(path, indent, level); + } catch { + return rawJSON(expr, path, indent, level); + } +} + +/** Render an arbitrary JSON value verbatim (with re-indent for + * embedding) wrapped in a single span tagged with `path`. Used for + * `options` and other free-form leaf objects. */ +function rawJSON( + value: unknown, + path: ReadonlyArray, + indent: number, + level: number, +): Code { + let text = JSON.stringify(value, null, indent); + if (level > 0) { + const lead = ' '.repeat(level * indent); + text = text.replace(/\n/g, '\n' + lead); + } + return spanCode(text, { path }); } diff --git a/packages/gin/src/types/alias.ts b/packages/gin/src/types/alias.ts new file mode 100644 index 00000000..48fa4f00 --- /dev/null +++ b/packages/gin/src/types/alias.ts @@ -0,0 +1,207 @@ +import type { PathStepDef, TypeDef } from '../schema'; +import type { Registry } from '../registry'; +import { Value } from '../value'; +import { + type Call, + type CompatOptions, + type GetSet, + type Init, + type Prop, + type PropSpec, + type Rnd, + Type, +} from '../type'; +import type { TypeScope } from '../type-scope'; +import { z } from 'zod'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; + + +export interface AliasOptions { + name: string; +} + +/** + * AliasType — a bare-name reference. Covers two roles via a single + * runtime class: a lazy reference to a registered named type, and an + * unbound type-parameter placeholder. Whichever role applies is + * scope-driven, never structural. + * + * JSON shape: `{name: 'X'}` (bare — no peers like `options`, + * `generic`, `props`, etc.; those would route to a class instead). + * + * Resolution: `this.resolve(extra?)` walks an optional caller- + * supplied `extra` scope first, then falls back to `this.scope`. The + * caller passes `extra` to override the captured scope at access + * time — this is how call-site generic bindings (e.g. `` on + * a path step) reach the AliasTypes inside a fn's signature without + * rebuilding the type tree. Every value/access method takes an + * optional `scope` argument that propagates through children. + * - Hit on `extra` → caller's local layer (call-site bindings). + * - Hit on `this.scope` (LocalScope chain → Registry) → captured + * layer (generic placeholder bound by the enclosing fn, alias + * declared in `CallDef.types`, registered named type, built-in + * class instance). + * - Miss → AliasType behaves as an unbound placeholder (compatible + * with everything, validates anything, no props). + */ +export class AliasType extends Type { + static readonly NAME = 'alias'; + readonly name = AliasType.NAME; + + constructor(scope: TypeScope, options: AliasOptions) { + super(scope, options); + } + + static from(json: TypeDef, scope: TypeScope): AliasType { + return new AliasType(scope, { name: json.name }); + } + + static toSchema(_opts: SchemaOptions): z.ZodTypeAny { + // AliasType isn't a normal Type-union branch — its JSON shape + // `{name: ''}` collides with every named class. Schema + // consumers (LLM type union) don't surface AliasType directly; + // bare names route through the registered class / named-type + // branches at parse time. This stub exists for completeness. + return z.object({ name: z.string() }).passthrough(); + } + + static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { + return z.any(); + } + + /** Resolve via `extra` (caller-supplied call-site scope) first, then + * the captured `this.scope`. Returns undefined when unresolved (so + * callers can fall back to placeholder behavior). */ + private resolve(extra?: TypeScope): Type | undefined { + if (extra) { + const t = extra.lookup(this.options.name); + if (t) return t; + } + return this.scope.lookup(this.options.name); + } + + // ─── delegating ops ───────────────────────────────────────────────────── + // When resolved, every value-side op delegates to the target — and + // forwards `scope` so AliasTypes nested inside the resolved target + // also see the call-site bindings. + // When unresolved, behave as a permissive placeholder: + // valid/compatible accept anything, props is empty, etc. + + valid(raw: unknown, scope?: TypeScope): boolean { + const t = this.resolve(scope); + return t ? t.valid(raw, scope) : true; + } + + parse(json: unknown, scope?: TypeScope): Value { + const t = this.resolve(scope); + if (!t) return new Value(this, json); + const v = t.parse(json, scope); + return new Value(this, v.raw); + } + + encode(raw: any, scope?: TypeScope): any { + const t = this.resolve(scope); + return t ? t.encode(raw, scope) : raw; + } + + create(): any { + const t = this.resolve(); + return t ? t.create() : null; + } + + random(rnd: Rnd): any { + const t = this.resolve(); + return t ? t.random(rnd) : null; + } + + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { + const t = this.resolve(scope); + return t ? t.compatible(other, opts, scope) : true; + } + + flexible(): boolean { return true; } + + /** Unbound aliases are universal placeholders; resolved aliases + * inherit the target's classification (most concrete types are not + * universal, so this defaults to false once resolved). */ + isUniversal(): boolean { + const t = this.resolve(); + return t ? t.isUniversal() : true; + } + + or(other: Type): Type { + const t = this.resolve(); + return t ? t.or(other) : this; + } + + /** Collapse to the resolved target — used by callers that prefer a + * concrete type over a lazy alias when both exist. */ + simplify(scope?: TypeScope): Type { + return this.resolve(scope) ?? this; + } + + narrow(local: Partial): AliasOptions { + return { name: local.name ?? this.options.name }; + } + + props(scope?: TypeScope): Record { + const t = this.resolve(scope); + return t ? t.props(scope) : super.props(scope); + } + + get(scope?: TypeScope): GetSet | undefined { + return this.resolve(scope)?.get(scope); + } + + call(scope?: TypeScope): Call | undefined { + return this.resolve(scope)?.call(scope); + } + + init(scope?: TypeScope): Init | undefined { + return this.resolve(scope)?.init(scope); + } + + follow(step: PathStepDef, scope?: TypeScope): Type | undefined { + return this.resolve(scope)?.follow(step, scope); + } + + /** Bare-name JSON shape. Unconditional — `{name: this.options.name}`, + * no `options` wrapper. Round-trip relies on the parse-side scope to + * rebuild the AliasType. */ + toJSON(): TypeDef { + return { name: this.options.name }; + } + + clone(): AliasType { + return new AliasType(this.scope, { ...this.options }); + } + + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + this.options.name; + } + + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { + // Lazy so recursive named types (Node → list) don't blow the stack. + return this.describeType(z.lazy(() => { + const t = this.resolve(); + return t ? t.toValueSchema(opts) : z.any(); + }), opts); + } + + toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + return this.describeType(z.lazy(() => { + const t = this.resolve(); + return t ? t.toNewSchema(opts) : z.any(); + }), opts, 'NewValue_'); + } + + /** An alias IS a name — instance schema is just `{name: }`. + * Lazy so self-referential named types (Node → list) don't + * infinite-recurse. */ + toInstanceSchema(): z.ZodTypeAny { + return z.lazy(() => { + const t = this.resolve(); + return t ? t.toInstanceSchema() : z.object({ name: z.literal(this.options.name) }); + }); + } +} diff --git a/packages/gin/src/types/and.ts b/packages/gin/src/types/and.ts index 196f39f6..ff52ff3f 100644 --- a/packages/gin/src/types/and.ts +++ b/packages/gin/src/types/and.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { PropDef, TypeDef } from '../schema'; import { Value } from '../value'; import { Call, type CompatOptions, GetSet, type Prop, PropSpec, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface AndOptions { @@ -26,9 +27,10 @@ export class AndType extends Type { static readonly NAME = 'and'; readonly name = AndType.NAME; - static from(json: TypeDef, registry: Registry): AndType { - const parts = ((json.options?.types ?? []) as TypeDef[]).map((t) => registry.parse(t)); - return new AndType(registry, parts); + static from(json: TypeDef, scope: TypeScope): AndType { + const registry = scope.registry; + const parts = ((json.options?.types ?? []) as TypeDef[]).map((t) => scope.parse(t)); + return new AndType(scope, parts); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,22 +44,22 @@ export class AndType extends Type { return opts.Expr; } - constructor(registry: Registry, parts: Type[]) { - super(registry, { parts }); + constructor(scope: TypeScope, parts: Type[]) { + super(scope, { parts }); } get parts(): Type[] { return this.options.parts; } - valid(raw: unknown): raw is any { - return this.parts.every((p) => p.valid(raw)); + valid(raw: unknown, scope?: TypeScope): raw is any { + return this.parts.every((p) => p.valid(raw, scope)); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { // Every part must accept the raw value. for (const p of this.parts) { - if (!p.valid(json)) { + if (!p.valid(json, scope)) { throw new TypeError({ path: [], code: 'and.constraint', message: `and: value fails part ${p.name}`, severity: 'error', @@ -67,9 +69,9 @@ export class AndType extends Type { return new Value(this, json); } - encode(raw: any): any { + encode(raw: any, scope?: TypeScope): any { // Take any part's dump (they should all agree on valid values). - return this.parts[0]?.encode(raw) ?? raw; + return this.parts[0]?.encode(raw, scope) ?? raw; } create(): any { @@ -92,9 +94,9 @@ export class AndType extends Type { return this.registry.and(narrowed); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { // other assignable to And iff assignable to every part. - return this.parts.every((p) => p.compatible(other, opts)); + return this.parts.every((p) => p.compatible(other, opts, scope)); } /** Empty And vacuously matches anything — too broad for Registry.compatible. */ @@ -163,11 +165,11 @@ export class AndType extends Type { return new AndType(this.registry, this.parts.map((p) => p.clone())); } - toCode(): string { - return this.docsPrefix() + `and<${this.parts.map((p) => p.toCode()).join(', ')}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `and<${this.parts.map((p) => p.toCode(undefined, options)).join(', ')}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { if (this.parts.length === 0) return this.describeType(z.unknown(), opts); if (this.parts.length === 1) return this.describeType(this.parts[0]!.toValueSchema(opts), opts); const s = this.parts diff --git a/packages/gin/src/types/any.ts b/packages/gin/src/types/any.ts index 9a3c4176..56608d00 100644 --- a/packages/gin/src/types/any.ts +++ b/packages/gin/src/types/any.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -15,8 +16,9 @@ export class AnyType extends Type> { static readonly NAME = 'any'; readonly name = AnyType.NAME; - static from(_json: TypeDef, registry: Registry): AnyType { - return new AnyType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): AnyType { + const registry = scope.registry; + return new AnyType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { @@ -82,7 +84,7 @@ export class AnyType extends Type> { is: r.method({}, r.bool(), 'any.is', { generic: { T: r.any() } }), // Cast to target type T. Returns optional — null when the value // doesn't satisfy T. - as: r.method({}, r.optional(r.generic('T')), 'any.as', { generic: { T: r.any() } }), + as: r.method({}, r.optional(r.alias('T')), 'any.as', { generic: { T: r.any() } }), toText: r.method({}, r.text(), 'any.toText'), toBool: r.method({}, r.bool(), 'any.toBool'), eq: r.method({ other: r.any() }, r.bool(), 'any.eq'), @@ -98,9 +100,9 @@ export class AnyType extends Type> { return new AnyType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'any'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'any'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.any(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.any(), opts); } /** An instance of `any` is any TypeDef — only requires a `name: string`. */ toInstanceSchema(): z.ZodTypeAny { diff --git a/packages/gin/src/types/bool.ts b/packages/gin/src/types/bool.ts index 68a61ce5..2cc7fc6d 100644 --- a/packages/gin/src/types/bool.ts +++ b/packages/gin/src/types/bool.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; -import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; +import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { BoolOptions } from '../builder'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -15,8 +16,9 @@ export class BoolType extends Type { static readonly NAME = 'bool'; readonly name = BoolType.NAME; - static from(json: TypeDef, registry: Registry): BoolType { - return new BoolType(registry, (json.options ?? {}) as BoolOptions); + static from(json: TypeDef, scope: TypeScope): BoolType { + const registry = scope.registry; + return new BoolType(scope, (json.options ?? {}) as BoolOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -88,6 +90,24 @@ export class BoolType extends Type { }; } + /** + * Bool opts into `LoopExpr`'s while-loop semantics via + * `loopDynamic: true`. The loop's `over` expression is re-evaluated + * each iteration; iteration continues while the resulting bool is + * `true` and exits when it flips to `false`. The body sees `key` + * (iteration index, num) and `value` (current bool truth-value). + * No `loop` ExprDef is required — the engine drives iteration + * directly. + */ + get(): GetSet { + const r = this.registry; + return new GetSet({ + key: r.num({ whole: true, min: 0 }), + value: r.bool(), + loopDynamic: true, + }); + } + toJSON(): TypeDef { return { name: BoolType.NAME, @@ -99,9 +119,9 @@ export class BoolType extends Type { return new BoolType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'bool' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'bool' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.boolean(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.boolean(), opts); } toInstanceSchema(): z.ZodTypeAny { return z.object({ name: z.literal('bool') }).passthrough(); diff --git a/packages/gin/src/types/color.ts b/packages/gin/src/types/color.ts index 4ec1d74a..0c9bea88 100644 --- a/packages/gin/src/types/color.ts +++ b/packages/gin/src/types/color.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, Init, type Prop, type Rnd, Type, optionsCode } from import type { ColorOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -17,8 +18,9 @@ export class ColorType extends Type { static readonly NAME = 'color'; readonly name = ColorType.NAME; - static from(json: TypeDef, registry: Registry): ColorType { - return new ColorType(registry, (json.options ?? {}) as ColorOptions); + static from(json: TypeDef, scope: TypeScope): ColorType { + const registry = scope.registry; + return new ColorType(scope, (json.options ?? {}) as ColorOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -28,8 +30,10 @@ export class ColorType extends Type { }).meta({ aid: 'Type_color' }); } - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { - return z.number().int().min(0).max(0xffffffff); + static toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + // Defer to canonical — base derives the obj shape ({r, g, b, a?}) + // from `init.args`, matching the runtime contract. + return new ColorType(opts.registry, {}).toNewSchema(opts); } valid(raw: unknown): raw is number { @@ -130,9 +134,9 @@ export class ColorType extends Type { return new ColorType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'color' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'color' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is a 32-bit integer (0xRRGGBBAA or 0xRRGGBB depending on hasAlpha). return this.describeType(z.number().int().min(0).max(0xffffffff), opts); } diff --git a/packages/gin/src/types/date.ts b/packages/gin/src/types/date.ts index 901f995b..cffee476 100644 --- a/packages/gin/src/types/date.ts +++ b/packages/gin/src/types/date.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../t import type { DateOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -16,8 +17,9 @@ export class DateType extends Type { static readonly NAME = 'date'; readonly name = DateType.NAME; - static from(json: TypeDef, registry: Registry): DateType { - return new DateType(registry, (json.options ?? {}) as DateOptions); + static from(json: TypeDef, scope: TypeScope): DateType { + const registry = scope.registry; + return new DateType(scope, (json.options ?? {}) as DateOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -144,9 +146,9 @@ export class DateType extends Type { return new DateType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'date' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'date' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is an ISO date string (YYYY-MM-DD). return this.describeType( z.string().regex(/^\d{4}-\d{2}-\d{2}/, 'expected ISO 8601 date'), diff --git a/packages/gin/src/types/duration.ts b/packages/gin/src/types/duration.ts index 0e420fd6..183f8e0b 100644 --- a/packages/gin/src/types/duration.ts +++ b/packages/gin/src/types/duration.ts @@ -1,9 +1,10 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, Init, type Prop, type Rnd, Type } from '../type'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -16,8 +17,9 @@ export class DurationType extends Type> { static readonly NAME = 'duration'; readonly name = DurationType.NAME; - static from(_json: TypeDef, registry: Registry): DurationType { - return new DurationType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): DurationType { + const registry = scope.registry; + return new DurationType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { @@ -25,7 +27,14 @@ export class DurationType extends Type> { .meta({ aid: 'Type_duration' }); } - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.number(); } + static toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + // Defer to the canonical instance — base `toNewSchema` already + // derives the right shape from `init.args` when a constructor is + // declared, so duration's `new` schema lines up with the runtime + // contract (an obj of {days?, hours?, minutes?, seconds?, ms?}) + // instead of a bare number. + return new DurationType(opts.registry, {}).toNewSchema(opts); + } valid(raw: unknown): raw is number { return typeof raw === 'number' && !Number.isNaN(raw); @@ -104,9 +113,9 @@ export class DurationType extends Type> { return new DurationType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'duration'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'duration'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is a number of milliseconds. return this.describeType(z.number(), opts); } diff --git a/packages/gin/src/types/enum.ts b/packages/gin/src/types/enum.ts index ed1e31cd..b9dfdcc8 100644 --- a/packages/gin/src/types/enum.ts +++ b/packages/gin/src/types/enum.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -21,10 +22,11 @@ export class EnumType extends Type> { static readonly NAME = 'enum'; readonly name = EnumType.NAME; - static from(json: TypeDef, registry: Registry): EnumType { - const V = json.generic?.V ? registry.parse(json.generic.V) : registry.text(); + static from(json: TypeDef, scope: TypeScope): EnumType { + const registry = scope.registry; + const V = json.generic?.V ? scope.parse(json.generic.V) : registry.text(); const values = (json.options?.values ?? {}) as Record; - return new EnumType(registry, V, { values }); + return new EnumType(scope, V, { values }); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -40,22 +42,22 @@ export class EnumType extends Type> { return z.union([z.string(), z.number()]); } - constructor(registry: Registry, value: Type, options: EnumOptions) { - super(registry, options, { V: value }); + constructor(scope: TypeScope, value: Type, options: EnumOptions) { + super(scope, options, { V: value }); } get value(): Type { return this.generic.V as Type; } - valid(raw: unknown): raw is RuntimeOf { - if (!this.value.valid(raw)) return false; + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + if (!this.value.valid(raw, scope)) return false; return Object.values(this.options.values).some((v) => v === raw); } - parse(json: unknown): Value { - const inner = this.value.parse(json); - if (!this.valid(inner.raw)) { + parse(json: unknown, scope?: TypeScope): Value { + const inner = this.value.parse(json, scope); + if (!this.valid(inner.raw, scope)) { throw new TypeError({ path: [], code: 'enum.not-a-member', message: `enum.parse: ${String(inner.raw)} is not one of ${Object.values(this.options.values).join(', ')}`, @@ -65,8 +67,8 @@ export class EnumType extends Type> { return new Value(this, inner.raw); } - encode(raw: RuntimeOf): JSONOf { - return this.value.encode(raw); + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { + return this.value.encode(raw, scope); } create(): RuntimeOf { @@ -90,9 +92,9 @@ export class EnumType extends Type> { ); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof EnumType)) return false; - if (!this.value.compatible(other.value, opts)) return false; + if (!this.value.compatible(other.value, opts, scope)) return false; if (!opts?.value) return true; // value-mode: other's values must be a subset of ours return Object.values(other.options.values).every((v) => @@ -154,12 +156,12 @@ export class EnumType extends Type> { ); } - toCode(): string { - const body = `enum<${this.value.toCode()}>` + optionsCode(this.options.values as Record); - return this.docsPrefix() + body; + toCode(_registry?: Registry, options?: CodeOptions): string { + const body = `enum<${this.value.toCode(undefined, options)}>` + optionsCode(this.options.values as Record); + return this.docsPrefix(options) + body; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Zod v4's z.enum accepts a Record — same shape as EnumOptions.values. return this.describeType( z.enum(this.options.values as Record), diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 85281e21..a8d275e6 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -1,10 +1,11 @@ -import type { Registry } from '../registry'; import type { ExprDef, TypeDef } from '../schema'; +import type { Registry } from '../registry'; import { Value } from '../value'; -import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderGenerics } from '../type'; -import { decodeCall, encodeCall } from '../spec'; +import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderCallTypes, renderGenerics } from '../type'; +import { decodeCall } from '../spec'; +import { LocalScope, type TypeScope } from '../type-scope'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema } from '../schemas'; /** @@ -17,30 +18,46 @@ import { callDefSchema } from '../schemas'; * shape apply meaningfully to function bodies. */ export class FnType extends Type> { - static readonly NAME = 'function'; + static readonly NAME = 'fn'; /** fn's signature IS its structure — call is natively consumed. */ static readonly consumes = ['call'] as const; readonly name = FnType.NAME; readonly _call: Call; - static from(json: TypeDef, registry: Registry): FnType { + static from(json: TypeDef, scope: TypeScope): FnType { + const registry = scope.registry; + // Generics declared on the fn — each entry's value is a CONSTRAINT + // type that bindings supplied at call sites must satisfy. The + // generic NAME itself stays unresolved in the captured scope: bare + // `{name: 'R'}` inside the call signature parses as an AliasType + // placeholder, NOT bound to its constraint. Concrete resolution + // only happens through call-site bindings (a CallStep's `generic` + // map layered into a LocalScope at invocation). + // + // Use `registry.any()` as the constraint when the parameter is + // unconstrained. A self-reference (`R: alias('R')`) also works as + // an unconstrained declaration — the alias resolves to itself in + // any context that doesn't supply a binding. const generic: Record = {}; + const local = new LocalScope(scope); if (json.generic) { - for (const [k, def] of Object.entries(json.generic)) generic[k] = registry.parse(def); + for (const [k, def] of Object.entries(json.generic)) { + generic[k] = local.parse(def); + } } if (!json.call) { - return new FnType(registry, new Call({ + return new FnType(local, new Call({ args: registry.any() as Type, returns: registry.any(), }), generic); } - return new FnType(registry, decodeCall(json.call, registry), generic); + return new FnType(local, decodeCall(json.call, local), generic); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ - name: z.literal('function'), + name: z.literal('fn'), call: callDefSchema(opts).optional(), }).meta({ aid: 'Type_function' }); } @@ -53,22 +70,22 @@ export class FnType extends Type> { } constructor( - registry: Registry, + scope: TypeScope, call: Call | ConstructorParameters[0], generic: Record = {}, ) { - super(registry, {}, generic); + super(scope, {}, generic); this._call = call instanceof Call ? call : new Call(call); } - valid(raw: unknown): boolean { + valid(raw: unknown, _scope?: TypeScope): boolean { if (typeof raw === 'function') return true; if (typeof raw === 'string') return true; if (raw && typeof raw === 'object' && 'kind' in (raw as Record)) return true; return false; } - parse(json: unknown): Value { + parse(json: unknown, _scope?: TypeScope): Value { // Functions aren't JSON-serializable; accept either a string ref or an // ExprDef (e.g. { kind: 'lambda' }). Native JS functions can only come // from in-process construction, not JSON parse. @@ -80,7 +97,7 @@ export class FnType extends Type> { return new Value(this, null as any); } - encode(raw: ((...args: any[]) => any) | ExprDef | string): any { + encode(raw: ((...args: any[]) => any) | ExprDef | string, _scope?: TypeScope): any { if (typeof raw === 'string') return raw; if (typeof raw === 'function') return null; // native, not serializable return raw; @@ -106,13 +123,20 @@ export class FnType extends Type> { return r.fn(args as Type, returns, throws, this.generic); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof FnType)) return false; - // args: contravariant — this.args must accept other.args - if (!this._call.args.compatible(other._call.args, opts)) return false; - // returns: covariant — other.returns must be compatible with this.returns + // Bivariant on args: a satisfier with narrower args (e.g. `num.eq` + // takes `other: num`) is accepted as a witness of a wider-args + // interface (e.g. `iface.eq` takes `other: any`). This is the + // pragmatic structural-interface check most gin code wants — and + // matches TypeScript's default bivariant method-parameter rule. + // Strict-subtype variance (contravariant args / covariant returns) + // is what consumers like edit-compat want; those should split + // args + returns and use `compatible` directionally per side + // rather than calling `FnType.compatible` whole. + if (!this._call.args.compatible(other._call.args, opts, scope)) return false; if (this._call.returns && other._call.returns) { - if (!this._call.returns.compatible(other._call.returns, opts)) return false; + if (!this._call.returns.compatible(other._call.returns, opts, scope)) return false; } return true; } @@ -134,12 +158,12 @@ export class FnType extends Type> { return {}; } - call(): Call { + call(_scope?: TypeScope): Call { return this._call; } - props(): Record { - return super.props() as Record; + props(scope?: TypeScope): Record { + return super.props(scope) as Record; } toJSON(): TypeDef { @@ -149,7 +173,7 @@ export class FnType extends Type> { : Object.fromEntries(genericKeys.map((k) => [k, this.generic[k]!.toJSON()])); return { name: FnType.NAME, - call: encodeCall(this._call), + call: this._call.toJSON(), generic, }; } @@ -170,13 +194,13 @@ export class FnType extends Type> { ); } - toCode(): string { - const ret = this._call.returns?.toCode() ?? 'void'; - return this.docsPrefix() - + `${renderGenerics(this.generic)}(${formatParams(this._call.args)}): ${ret}`; + toCode(_registry?: Registry, options?: CodeOptions): string { + const ret = this._call.returns?.toCode(undefined, options) ?? 'void'; + return this.docsPrefix(options) + + `${renderGenerics(this.generic, options)}${renderCallTypes(this._call.types, options)}(${formatParams(this._call.args, options)}): ${ret}`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Functions aren't JSON-serializable. Accept a native id (string) or // an inline lambda ExprDef (object with `kind`). LLMs shouldn't be // generating raw function values — use native id strings. diff --git a/packages/gin/src/types/generic.ts b/packages/gin/src/types/generic.ts deleted file mode 100644 index cf3f74ed..00000000 --- a/packages/gin/src/types/generic.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Registry } from '../registry'; -import type { TypeDef } from '../schema'; -import { Value } from '../value'; -import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; -import { z } from 'zod'; -import type { SchemaOptions } from '../node'; - - -export interface GenericOptions { - name: string; -} - -/** - * GenericType — a type-parameter placeholder (e.g. `V`, `R`). It - * carries no structure itself; its meaning is determined by the - * bindings in scope. `bind(bindings)` on an enclosing type substitutes - * this placeholder with the bound Type. - * - * Before binding, Generic is maximally permissive — validation passes - * any value, compatibility is true, props is empty. This mirrors how - * TypeScript treats unconstrained type parameters inside a generic body. - */ -export class GenericType extends Type { - static readonly NAME = 'generic'; - readonly name = GenericType.NAME; - - static from(json: TypeDef, registry: Registry): GenericType { - const name = (json.options?.name ?? 'T') as string; - return new GenericType(registry, { name }); - } - - static toSchema(opts: SchemaOptions): z.ZodTypeAny { - return z.object({ - name: z.literal('generic'), - options: z.object({ name: z.string() }), - }).meta({ aid: 'Type_generic' }); - } - - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.any(); } - - valid(_raw: unknown): _raw is any { - return true; - } - - parse(json: unknown): Value { - return new Value(this, json); - } - - encode(raw: any): any { - return raw; - } - - create(): any { - return null; - } - - random(_rnd: Rnd): any { - return null; - } - - compatible(_other: Type, _opts?: CompatOptions): boolean { - return true; - } - - flexible(): boolean { - return true; - } - - isUniversal(): boolean { - return true; - } - - or(_other: Type): Type { - return this; - } - - narrow(local: Partial): GenericOptions { - // Renaming a generic placeholder is a structural rename, not a narrow. - return { name: local.name ?? this.options.name }; - } - - /** Resolve self against the given bindings — the terminal case of the - * polymorphic Type.substitute walk. */ - substitute(bindings: Record): Type { - return bindings[this.options.name] ?? this; - } - - props(): Record { - return {}; - } - - toJSON(): TypeDef { - return { - name: GenericType.NAME, - options: { name: this.options.name }, - }; - } - - clone(): GenericType { - return new GenericType(this.registry, { ...this.options }); - } - - toCode(): string { return this.docsPrefix() + this.options.name; } - - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { - // Unbound placeholder — no concrete shape constraint. Callers that - // need tight schemas should `.bind()` the generic first. - return this.describeType(z.any(), opts); - } - - /** Unbound generic — its instance schema mirrors `any`: accepts any TypeDef. */ - toInstanceSchema(): z.ZodTypeAny { - return z.object({ name: z.string() }).passthrough(); - } -} diff --git a/packages/gin/src/types/iface.ts b/packages/gin/src/types/iface.ts index 3a149f64..edf4a0f4 100644 --- a/packages/gin/src/types/iface.ts +++ b/packages/gin/src/types/iface.ts @@ -1,5 +1,6 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; +import type { Registry } from '../registry'; import { Value } from '../value'; import { Call, @@ -9,10 +10,11 @@ import { type PropSpec, type Rnd, Type, + joinAuto, } from '../type'; -import { decodeCall, decodeGetSet, decodeProps, encodeCall, encodeGetSet, encodeProps } from '../spec'; +import { decodeCall, decodeGetSet, decodeProps, encodeProps } from '../spec'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema, getSetDefSchema, propDefSchema } from '../schemas'; /** @@ -34,11 +36,12 @@ export class IfaceType extends Type> { readonly _get?: GetSet; readonly _call?: Call; - static from(json: TypeDef, registry: Registry): IfaceType { - return new IfaceType(registry, { - props: json.props ? decodeProps(json.props, registry) : {}, - get: json.get ? decodeGetSet(json.get, registry) : undefined, - call: json.call ? decodeCall(json.call, registry) : undefined, + static from(json: TypeDef, scope: TypeScope): IfaceType { + const registry = scope.registry; + return new IfaceType(scope, { + props: json.props ? decodeProps(json.props, scope) : {}, + get: json.get ? decodeGetSet(json.get, scope) : undefined, + call: json.call ? decodeCall(json.call, scope) : undefined, }); } @@ -56,10 +59,10 @@ export class IfaceType extends Type> { } constructor( - registry: Registry, + scope: TypeScope, spec: { props?: Record; get?: GetSet; call?: Call }, ) { - super(registry, {}); + super(scope, {}); const p: Record = {}; if (spec.props) { for (const [k, v] of Object.entries(spec.props)) p[k] = Prop.from(v); @@ -69,17 +72,17 @@ export class IfaceType extends Type> { this._call = spec.call; } - valid(_raw: unknown): _raw is any { + valid(_raw: unknown, _scope?: TypeScope): _raw is any { // Runtime values don't directly "satisfy" interfaces — interface // satisfaction is a TYPE-level check (see compatible()). return true; } - parse(json: unknown): Value { + parse(json: unknown, _scope?: TypeScope): Value { return new Value(this, json); } - encode(raw: any): any { + encode(raw: any, _scope?: TypeScope): any { return raw; } @@ -122,26 +125,26 @@ export class IfaceType extends Type> { }); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { // "other satisfies this interface" — structural. - const theirProps = other.props(); + const theirProps = other.props(scope); for (const [name, prop] of Object.entries(this._props)) { const their = theirProps[name]; if (!their) return false; - if (!prop.type.compatible(their.type, opts)) return false; + if (!prop.type.compatible(their.type, opts, scope)) return false; } if (this._get) { - const their = other.get(); + const their = other.get(scope); if (!their) return false; - if (!this._get.key.compatible(their.key, opts)) return false; - if (!this._get.value.compatible(their.value, opts)) return false; + if (!this._get.key.compatible(their.key, opts, scope)) return false; + if (!this._get.value.compatible(their.value, opts, scope)) return false; } if (this._call) { - const their = other.call(); + const their = other.call(scope); if (!their) return false; - if (!this._call.args.compatible(their.args, opts)) return false; + if (!this._call.args.compatible(their.args, opts, scope)) return false; if (this._call.returns && their.returns) { - if (!this._call.returns.compatible(their.returns, opts)) return false; + if (!this._call.returns.compatible(their.returns, opts, scope)) return false; } } return true; @@ -171,15 +174,15 @@ export class IfaceType extends Type> { return {}; } - props(): Record { - return { ...(super.props() as Record), ...this._props }; + props(scope?: TypeScope): Record { + return { ...(super.props(scope) as Record), ...this._props }; } - get(): GetSet | undefined { + get(_scope?: TypeScope): GetSet | undefined { return this._get; } - call(): Call | undefined { + call(_scope?: TypeScope): Call | undefined { return this._call; } @@ -187,8 +190,8 @@ export class IfaceType extends Type> { return { name: IfaceType.NAME, props: Object.keys(this._props).length > 0 ? encodeProps(this._props) : undefined, - get: this._get ? encodeGetSet(this._get) : undefined, - call: this._call ? encodeCall(this._call) : undefined, + get: this._get?.toJSON(), + call: this._call?.toJSON(), }; } @@ -200,27 +203,28 @@ export class IfaceType extends Type> { return new IfaceType(this.registry, { props: p, get: this._get, call: this._call }); } - toCode(): string { + toCode(_registry?: Registry, options?: CodeOptions): string { + const includeComments = options?.includeComments !== false; const parts: string[] = []; for (const [name, prop] of Object.entries(this._props)) { const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; const label = optional ? `${name}?` : name; - const propDocs = prop.docs ? `/* ${prop.docs} */ ` : ''; - parts.push(`${propDocs}${label}: ${t.toCode()}`); + const propDocs = prop.docs && includeComments ? `/* ${prop.docs} */ ` : ''; + parts.push(`${propDocs}${label}: ${t.toCode(undefined, options)}`); } if (this._get) { - parts.push(`[key: ${this._get.key.toCode()}]: ${this._get.value.toCode()}`); + parts.push(`[key: ${this._get.key.toCode(undefined, options)}]: ${this._get.value.toCode(undefined, options)}`); } if (this._call) { - const ret = this._call.returns?.toCode() ?? 'void'; - parts.push(`(${this._call.args.toCode()}): ${ret}`); + const ret = this._call.returns?.toCode(undefined, options) ?? 'void'; + parts.push(`(${this._call.args.toCode(undefined, options)}): ${ret}`); } - const body = parts.length === 0 ? 'iface' : `iface{${parts.join(', ')}}`; - return this.docsPrefix() + body; + const body = parts.length === 0 ? 'iface' : `iface{${joinAuto(parts)}}`; + return this.docsPrefix(options) + body; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const mode = opts?.includeDocs ?? 'none'; // Structural: any object carrying the declared props is acceptable. const shape: Record = {}; diff --git a/packages/gin/src/types/index.ts b/packages/gin/src/types/index.ts index 0f42f41a..00e1d3ed 100644 --- a/packages/gin/src/types/index.ts +++ b/packages/gin/src/types/index.ts @@ -1,3 +1,4 @@ +export { AliasType, type AliasOptions } from './alias'; export { AnyType } from './any'; export { AndType, type AndOptions } from './and'; export { BoolType } from './bool'; @@ -6,7 +7,6 @@ export { DateType } from './date'; export { DurationType } from './duration'; export { EnumType, type EnumOptions } from './enum'; export { FnType } from './fn'; -export { GenericType, type GenericOptions } from './generic'; export { IfaceType } from './iface'; export { LiteralType, type LiteralOptions } from './literal'; export { ListType } from './list'; @@ -18,7 +18,6 @@ export { NumType } from './num'; export { ObjType } from './obj'; export { OptionalType } from './optional'; export { OrType, type OrOptions } from './or'; -export { RefType, type RefOptions } from './ref'; export { TextType } from './text'; export { TimestampType } from './timestamp'; export { TupleType, type TupleOptions } from './tuple'; diff --git a/packages/gin/src/types/list.ts b/packages/gin/src/types/list.ts index 8ebfaa35..c306003e 100644 --- a/packages/gin/src/types/list.ts +++ b/packages/gin/src/types/list.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } fr import type { ListOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue } from '../json-type'; @@ -23,9 +24,10 @@ export class ListType extends Type { static readonly NAME = 'list'; readonly name = ListType.NAME; - static from(json: TypeDef, registry: Registry): ListType { - const item = json.generic?.V ? registry.parse(json.generic.V) : registry.any(); - return new ListType(registry, item, (json.options ?? {}) as ListOptions); + static from(json: TypeDef, scope: TypeScope): ListType { + const registry = scope.registry; + const item = json.generic?.V ? scope.parse(json.generic.V) : registry.any(); + return new ListType(scope, item, (json.options ?? {}) as ListOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -44,31 +46,31 @@ export class ListType extends Type { return z.array(opts.Expr); } - constructor(registry: Registry, item: Type, options: ListOptions = {}) { - super(registry, options, { V: item }); + constructor(scope: TypeScope, item: Type, options: ListOptions = {}) { + super(scope, options, { V: item }); } get item(): Type { return this.generic.V as Type; } - valid(raw: unknown): raw is Value[] { + valid(raw: unknown, scope?: TypeScope): raw is Value[] { if (!Array.isArray(raw)) return false; const { minLength, maxLength } = this.options; if (minLength !== undefined && raw.length < minLength) return false; if (maxLength !== undefined && raw.length > maxLength) return false; - return raw.every((x) => x instanceof Value && x.type.valid(x.raw)); + return raw.every((x) => x instanceof Value && x.type.valid(x.raw, scope)); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { if (!Array.isArray(json)) { throw new TypeError({ path: [], code: 'list.invalid', message: `list.parse: expected array, got ${typeof json}`, severity: 'error', }); } - const raw: Value[] = json.map((x) => this.registry.parseValue(x, this.item)); - if (!this.valid(raw)) { + const raw: Value[] = json.map((x) => this.registry.parseValue(x, this.item, scope)); + if (!this.valid(raw, scope)) { throw new TypeError({ path: [], code: 'list.constraint', message: 'list.parse: length constraints violated', severity: 'error', @@ -79,7 +81,7 @@ export class ListType extends Type { /** Each element becomes a `JSONValue` envelope so nested subtypes * round-trip through JSON. */ - encode(raw: Value[]): JSONValue[] { + encode(raw: Value[], _scope?: TypeScope): JSONValue[] { return raw.map((v) => v.toJSON()); } @@ -102,9 +104,9 @@ export class ListType extends Type { return this.registry.list(item); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof ListType)) return false; - if (!this.item.compatible(other.item, opts)) return false; + if (!this.item.compatible(other.item, opts, scope)) return false; if (!opts?.value) return true; const a = this.options, b = other.options; if (a.minLength !== undefined && (b.minLength === undefined || b.minLength < a.minLength)) return false; @@ -194,10 +196,10 @@ export class ListType extends Type { unique: r.method({}, lstV, 'list.unique'), duplicates: r.method({}, lstV, 'list.duplicates'), - map: r.method({ fn: fnValueIndex(r.generic('R')) }, r.list(r.generic('R')), 'list.map', { generic: { R: r.any() } }), + map: r.method({ fn: fnValueIndex(r.alias('R')) }, r.list(r.alias('R')), 'list.map', { generic: { R: r.any() } }), filter: r.method({ fn: fnValueIndex(bool) }, lstV, 'list.filter'), find: r.method({ fn: fnValueIndex(bool) }, optV, 'list.find'), - reduce: r.method({ fn: r.fn(r.obj({ acc: { type: r.generic('R') }, value: { type: V }, index: { type: num } }), r.generic('R')), initial: r.generic('R') }, r.generic('R'), 'list.reduce', { generic: { R: r.any() } }), + reduce: r.method({ fn: r.fn(r.obj({ acc: { type: r.alias('R') }, value: { type: V }, index: { type: num } }), r.alias('R')), initial: r.alias('R') }, r.alias('R'), 'list.reduce', { generic: { R: r.any() } }), some: r.method({ fn: fnValueIndex(bool) }, bool, 'list.some'), every: r.method({ fn: fnValueIndex(bool) }, bool, 'list.every'), sort: r.method({ fn: r.optional(r.fn(r.obj({ a: { type: V }, b: { type: V } }), num)) }, lstV, 'list.sort'), @@ -222,11 +224,15 @@ export class ListType extends Type { return new ListType(this.registry, this.item.clone() as Type, { ...this.options }); } - toCode(): string { - return this.docsPrefix() + `list<${this.item.toCode()}>` + optionsCode(this.options); + toCode(_registry?: Registry, options?: CodeOptions): string { + // `minLength=0` is a no-op; skip. `maxLength` only renders when + // explicitly set. + return this.docsPrefix(options) + `list<${this.item.toCode(undefined, options)}>` + optionsCode(this.options, { + minLength: 0, + }); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = z.array(this.item.toValueSchema(opts)); if (this.options.minLength !== undefined) s = s.min(this.options.minLength); if (this.options.maxLength !== undefined) s = s.max(this.options.maxLength); diff --git a/packages/gin/src/types/literal.ts b/packages/gin/src/types/literal.ts index 2d125855..2c89e7fb 100644 --- a/packages/gin/src/types/literal.ts +++ b/packages/gin/src/types/literal.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type PropSpec, type Rnd, Type, optionsCode } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -34,10 +35,11 @@ export class LiteralType extends Type> { readonly inner: Type; - static from(json: TypeDef, registry: Registry): LiteralType { - const inner = json.generic?.T ? registry.parse(json.generic.T) : registry.any(); + static from(json: TypeDef, scope: TypeScope): LiteralType { + const registry = scope.registry; + const inner = json.generic?.T ? scope.parse(json.generic.T) : registry.any(); const value = (json.options as { value?: unknown } | undefined)?.value; - return new LiteralType(registry, inner, value); + return new LiteralType(scope, inner, value); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -54,8 +56,8 @@ export class LiteralType extends Type> { return z.any(); } - constructor(registry: Registry, inner: Type, value: T) { - super(registry, { value }, { T: inner }); + constructor(scope: TypeScope, inner: Type, value: T) { + super(scope, { value }, { T: inner }); this.inner = inner; } @@ -63,12 +65,12 @@ export class LiteralType extends Type> { return this.options.value; } - valid(raw: unknown): raw is RuntimeOf { - return this.inner.valid(raw) && raw === this.literal; + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return this.inner.valid(raw, scope) && raw === this.literal; } - parse(json: unknown): Value { - const inner = this.inner.parse(json); + parse(json: unknown, scope?: TypeScope): Value { + const inner = this.inner.parse(json, scope); if (inner.raw !== this.literal) { throw new TypeError({ path: [], code: 'literal.not-match', @@ -79,8 +81,8 @@ export class LiteralType extends Type> { return new Value(this, inner.raw); } - encode(raw: RuntimeOf): JSONOf { - return this.inner.encode(raw); + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { + return this.inner.encode(raw, scope); } create(): RuntimeOf { @@ -91,13 +93,13 @@ export class LiteralType extends Type> { return this.literal as RuntimeOf; } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (other instanceof LiteralType) { - return this.inner.compatible(other.inner, opts) && this.literal === other.literal; + return this.inner.compatible(other.inner, opts, scope) && this.literal === other.literal; } if (opts?.exact) return false; // Literal is compatible with its inner type (a literal IS a value of inner). - return this.inner.compatible(other, opts); + return this.inner.compatible(other, opts, scope); } /** literal (canonical with no declared value) delegates to any — too broad. */ @@ -139,12 +141,12 @@ export class LiteralType extends Type> { return new LiteralType(this.registry, this.inner.clone() as Type, this.literal); } - toCode(): string { - return this.docsPrefix() + `literal<${this.inner.toCode()}>` + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `literal<${this.inner.toCode(undefined, options)}>` + optionsCode({ value: this.literal }); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType( z.literal(this.literal as string | number | boolean | null), opts, diff --git a/packages/gin/src/types/map.ts b/packages/gin/src/types/map.ts index b4644750..7be4db95 100644 --- a/packages/gin/src/types/map.ts +++ b/packages/gin/src/types/map.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue } from '../json-type'; @@ -22,10 +23,11 @@ export class MapType extends Type, Record extends Type, Record, value: Type) { - super(registry, {}, { K: key, V: value }); + constructor(scope: TypeScope, key: Type, value: Type) { + super(scope, {}, { K: key, V: value }); } get key(): Type { @@ -53,18 +55,18 @@ export class MapType extends Type, Record; } - valid(raw: unknown): raw is Map, Value]> { + valid(raw: unknown, scope?: TypeScope): raw is Map, Value]> { if (!(raw instanceof Map)) return false; for (const [, entry] of raw as Map) { if (!Array.isArray(entry) || entry.length !== 2) return false; const [kv, vv] = entry; if (!(kv instanceof Value) || !(vv instanceof Value)) return false; - if (!kv.type.valid(kv.raw) || !vv.type.valid(vv.raw)) return false; + if (!kv.type.valid(kv.raw, scope) || !vv.type.valid(vv.raw, scope)) return false; } return true; } - parse(json: unknown): Value> { + parse(json: unknown, scope?: TypeScope): Value> { if (!Array.isArray(json)) { throw new TypeError({ path: [], code: 'map.invalid', @@ -77,8 +79,8 @@ export class MapType extends Type, Record = this.registry.parseValue(rawK, this.key); - const valV: Value = this.registry.parseValue(rawV, this.value); + const keyV: Value = this.registry.parseValue(rawK, this.key, scope); + const valV: Value = this.registry.parseValue(rawV, this.value, scope); m.set(keyV.raw, [keyV, valV]); } return new Value(this, m); @@ -87,7 +89,7 @@ export class MapType extends Type, Record, Value]>): Array<{ key: JSONValue; value: JSONValue }> { + encode(raw: Map, Value]>, _scope?: TypeScope): Array<{ key: JSONValue; value: JSONValue }> { return Array.from(raw, ([, [kv, vv]]) => ({ key: kv.toJSON(), value: vv.toJSON() })); } @@ -114,9 +116,9 @@ export class MapType extends Type, Record>): Type> { @@ -180,11 +182,11 @@ export class MapType extends Type, Record`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `map<${this.key.toCode(undefined, options)}, ${this.value.toCode(undefined, options)}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // LLM-friendly shape: an array of { key, value } objects. Not a // positional tuple — LLMs handle object keys more reliably. return this.describeType(z.array(z.object({ diff --git a/packages/gin/src/types/not.ts b/packages/gin/src/types/not.ts index be8ef95b..5c1d9e58 100644 --- a/packages/gin/src/types/not.ts +++ b/packages/gin/src/types/not.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface NotOptions { @@ -19,11 +20,12 @@ export class NotType extends Type { static readonly NAME = 'not'; readonly name = NotType.NAME; - static from(json: TypeDef, registry: Registry): NotType { + static from(json: TypeDef, scope: TypeScope): NotType { + const registry = scope.registry; const excluded = json.options?.excluded - ? registry.parse(json.options.excluded) + ? scope.parse(json.options.excluded) : registry.any(); - return new NotType(registry, excluded); + return new NotType(scope, excluded); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -35,16 +37,16 @@ export class NotType extends Type { static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.any(); } - constructor(registry: Registry, readonly excluded: Type) { - super(registry, { excluded: excluded.toJSON() }); + constructor(scope: TypeScope, readonly excluded: Type) { + super(scope, { excluded: excluded.toJSON() }); } - valid(raw: unknown): raw is any { - return !this.excluded.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is any { + return !this.excluded.valid(raw, scope); } - parse(json: unknown): Value { - if (this.excluded.valid(json)) { + parse(json: unknown, scope?: TypeScope): Value { + if (this.excluded.valid(json, scope)) { throw new TypeError({ path: [], code: 'not.excluded', message: `not: value matches excluded type ${this.excluded.name}`, severity: 'error', @@ -53,7 +55,7 @@ export class NotType extends Type { return new Value(this, json); } - encode(raw: any): any { + encode(raw: any, _scope?: TypeScope): any { return raw; } @@ -72,10 +74,10 @@ export class NotType extends Type { return this.registry.not(excluded); } - compatible(other: Type, opts?: CompatOptions): boolean { - if (opts?.exact) return other instanceof NotType && this.excluded.exact(other.excluded); + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { + if (opts?.exact) return other instanceof NotType && this.excluded.exact(other.excluded, scope); // other must NOT be structurally compatible with excluded. - return !this.excluded.compatible(other, opts); + return !this.excluded.compatible(other, opts, scope); } flexible(): boolean { @@ -115,9 +117,9 @@ export class NotType extends Type { return new NotType(this.registry, this.excluded.clone()); } - toCode(): string { return this.docsPrefix() + `not<${this.excluded.toCode()}>`; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + `not<${this.excluded.toCode(undefined, options)}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const excluded = this.excluded.toValueSchema(opts); return this.describeType(z.any().refine( (v) => !excluded.safeParse(v).success, diff --git a/packages/gin/src/types/null.ts b/packages/gin/src/types/null.ts index cb322bf3..a946a058 100644 --- a/packages/gin/src/types/null.ts +++ b/packages/gin/src/types/null.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -16,8 +17,9 @@ export class NullType extends Type> { static readonly NAME = 'null'; readonly name = NullType.NAME; - static from(_json: TypeDef, registry: Registry): NullType { - return new NullType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): NullType { + const registry = scope.registry; + return new NullType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { @@ -88,9 +90,9 @@ export class NullType extends Type> { return new NullType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'null'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'null'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } toInstanceSchema(): z.ZodTypeAny { return z.object({ name: z.literal('null') }).passthrough(); diff --git a/packages/gin/src/types/nullable.ts b/packages/gin/src/types/nullable.ts index 7edaa1ad..d5aff256 100644 --- a/packages/gin/src/types/nullable.ts +++ b/packages/gin/src/types/nullable.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -17,11 +18,12 @@ export class NullableType extends Type> static readonly NAME = 'nullable'; readonly name = NullableType.NAME; - static from(json: TypeDef, registry: Registry): NullableType { + static from(json: TypeDef, scope: TypeScope): NullableType { + const registry = scope.registry; const inner = json.generic?.T - ? registry.parse(json.generic.T) + ? scope.parse(json.generic.T) : registry.any(); - return new NullableType(registry, inner); + return new NullableType(scope, inner); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -35,23 +37,23 @@ export class NullableType extends Type> return opts.Expr.nullable(); } - constructor(registry: Registry, readonly inner: Type) { - super(registry, {}, { T: inner }); + constructor(scope: TypeScope, readonly inner: Type) { + super(scope, {}, { T: inner }); } - valid(raw: unknown): raw is RuntimeOf { - return raw === null || this.inner.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return raw === null || this.inner.valid(raw, scope); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { if (json === null) return new Value(this, null as RuntimeOf); - const v = this.inner.parse(json); + const v = this.inner.parse(json, scope); return new Value(this, v.raw as RuntimeOf); } - encode(raw: RuntimeOf): JSONOf { + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { if (raw === null) return null as JSONOf; - return this.inner.encode(raw as RuntimeOf) as JSONOf; + return this.inner.encode(raw as RuntimeOf, scope) as JSONOf; } create(): RuntimeOf { @@ -70,12 +72,12 @@ export class NullableType extends Type> return this.registry.nullable(inner); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (other instanceof NullableType) { - return this.inner.compatible(other.inner, opts); + return this.inner.compatible(other.inner, opts, scope); } if (opts?.exact) return false; - return this.inner.compatible(other, opts); + return this.inner.compatible(other, opts, scope); } or(other: Type): Type { @@ -111,7 +113,7 @@ export class NullableType extends Type> value: r.prop(T, 'nullable.value'), isNull: r.method({}, r.bool(), 'nullable.isNull'), or: r.method({ fallback: T }, T, 'nullable.or'), - map: r.method({ fn: r.fn(r.obj({ value: { type: T } }), r.generic('R')) }, r.nullable(r.generic('R')), 'nullable.map', { generic: { R: r.any() } }), + map: r.method({ fn: r.fn(r.obj({ value: { type: T } }), r.alias('R')) }, r.nullable(r.alias('R')), 'nullable.map', { generic: { R: r.any() } }), }; } @@ -126,11 +128,11 @@ export class NullableType extends Type> return new NullableType(this.registry, this.inner.clone() as Type); } - toCode(): string { - return this.docsPrefix() + `nullable<${this.inner.toCode()}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `nullable<${this.inner.toCode(undefined, options)}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(this.inner.toValueSchema(opts).nullable(), opts); } diff --git a/packages/gin/src/types/num.ts b/packages/gin/src/types/num.ts index 405b9785..1a0bdc44 100644 --- a/packages/gin/src/types/num.ts +++ b/packages/gin/src/types/num.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } fr import type { NumOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -22,22 +23,23 @@ export class NumType extends Type { static readonly NAME = 'num'; readonly name = NumType.NAME; - static from(json: TypeDef, registry: Registry): NumType { - return new NumType(registry, (json.options ?? {}) as NumOptions); + static from(json: TypeDef, scope: TypeScope): NumType { + const registry = scope.registry; + return new NumType(scope, (json.options ?? {}) as NumOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ name: z.literal('num'), options: z.object({ - min: z.number().optional(), - max: z.number().optional(), - whole: z.boolean().optional(), - minPrecision: z.number().optional(), - maxPrecision: z.number().optional(), - prefix: z.string().optional(), - suffix: z.string().optional(), - }).optional(), + min: z.number().optional().describe('Only set when a real lower bound is part of the spec — e.g. "positive count" → min: 1, "age" → min: 0. Do NOT add `min: 0` to every num just because most numbers happen to be non-negative.'), + max: z.number().optional().describe('Only set when there is an actual upper bound — a percentage capped at 100, a year capped at 9999. Do NOT pick a generic ceiling like 1000/9999 to fill the field.'), + whole: z.boolean().optional().describe('Only set to true when the value is genuinely integral (counts, indices, ids). Leave unset (allow fractions) for measurements, ratios, etc.'), + minPrecision: z.number().optional().describe('Decimal-place floor. Almost never needed; omit unless the spec explicitly requires N decimal places.'), + maxPrecision: z.number().optional().describe('Decimal-place ceiling. Same rule as minPrecision — omit unless explicitly required.'), + prefix: z.string().optional().describe('Display-only prefix (e.g. "$"). Has no effect on validation. Omit unless rendering needs it.'), + suffix: z.string().optional().describe('Display-only suffix (e.g. "%"). Same as prefix — omit unless rendering needs it.'), + }).optional().describe('Omit entirely for ordinary numbers. Only include when the value has a real, named constraint worth enforcing on every parse.'), }).meta({ aid: 'Type_num' }); } @@ -232,9 +234,20 @@ export class NumType extends Type { return new NumType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'num' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { + // `minPrecision` / `maxPrecision` / `prefix` / `suffix` are + // display-only — skip when at their typical defaults so the + // type code stays focused on validation-relevant constraints + // (`min`, `max`, `whole`). + return this.docsPrefix(options) + 'num' + optionsCode(this.options, { + minPrecision: 1, + maxPrecision: 7, + prefix: '', + suffix: '', + }); + } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = this.options.whole ? z.number().int() : z.number(); if (this.options.min !== undefined) s = s.min(this.options.min); if (this.options.max !== undefined) s = s.max(this.options.max); diff --git a/packages/gin/src/types/obj.ts b/packages/gin/src/types/obj.ts index e78d36fc..fc2d7958 100644 --- a/packages/gin/src/types/obj.ts +++ b/packages/gin/src/types/obj.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef, PropDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, GetSet, Prop, type PropSpec, type Rnd, Type } from import { decodeProps, encodeProps } from '../spec'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue, RuntimeOf } from '../json-type'; import { propDefSchema } from '../schemas'; @@ -15,7 +16,7 @@ import { propDefSchema } from '../schemas'; * exposed via props() directly. Any number of fields, typed per-name. */ export class ObjType> extends Type> { - static readonly NAME = 'object'; + static readonly NAME = 'obj'; /** obj's fields ARE its structure — props is natively consumed. */ static readonly consumes = ['props'] as const; readonly name = ObjType.NAME; @@ -23,15 +24,15 @@ export class ObjType> extends Type; - static from(json: TypeDef, registry: Registry): ObjType { - const fieldDefs = (json.props ?? {}) as Record; - const fields = decodeProps(fieldDefs, registry); - return new ObjType(registry, fields); + static from(json: TypeDef, scope: TypeScope): ObjType { + const fieldDefs = json.props ?? {}; + const fields = decodeProps(fieldDefs, scope); + return new ObjType(scope, fields); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ - name: z.literal('object'), + name: z.literal('obj'), props: z.record(z.string(), propDefSchema(opts)).optional(), }).meta({ aid: 'Type_object' }); } @@ -42,8 +43,8 @@ export class ObjType> extends Type) { - super(registry, {}); + constructor(scope: TypeScope, fields: Record) { + super(scope, {}); // Normalize plain objects to Prop instances so methods are available. const normalized: Record = {}; for (const [k, v] of Object.entries(fields)) { @@ -52,7 +53,7 @@ export class ObjType> extends Type { + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return false; for (const [name] of Object.entries(this.fields)) { const v = (raw as Record)[name]; @@ -60,12 +61,12 @@ export class ObjType> extends Type { + parse(json: unknown, scope?: TypeScope): Value { if (typeof json !== 'object' || json === null || Array.isArray(json)) { throw new TypeError({ path: [], code: 'object.invalid', @@ -75,13 +76,13 @@ export class ObjType> extends Type = {}; for (const [name, prop] of Object.entries(this.fields)) { const input = (json as Record)[name]; - raw[name] = this.registry.parseValue(input, prop.type); + raw[name] = this.registry.parseValue(input, prop.type, scope); } return new Value(this, raw as RuntimeOf); } /** Each field becomes a `JSONValue` envelope. */ - encode(raw: RuntimeOf): JSONOf { + encode(raw: RuntimeOf, _scope?: TypeScope): JSONOf { const fields = raw as Record; const out: Record = {}; for (const [name] of Object.entries(this.fields)) { @@ -118,13 +119,23 @@ export class ObjType> extends Type> extends Type(this.registry, cloned); } - toCode(): string { + toCode(_registry?: Registry, options?: CodeOptions): string { const entries = Object.entries(this.fields); - if (entries.length === 0) return this.docsPrefix() + 'obj'; + if (entries.length === 0) return this.docsPrefix(options) + 'obj'; + const includeComments = options?.includeComments !== false; const parts = entries.map(([name, prop]) => { const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; const label = optional ? `${name}?` : name; - const propDocs = prop.docs ? `/* ${prop.docs} */ ` : ''; - return `${propDocs}${label}: ${t.toCode()}`; + const propDocs = prop.docs && includeComments ? `/* ${prop.docs} */ ` : ''; + return `${propDocs}${label}: ${t.toCode(undefined, options)}`; }); - return this.docsPrefix() + `obj{${parts.join(', ')}}`; + return this.docsPrefix(options) + `obj{${parts.join(', ')}}`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const mode = opts?.includeDocs ?? 'none'; const shape: Record = {}; for (const [name, prop] of Object.entries(this.fields)) { @@ -248,7 +260,7 @@ export class ObjType> extends Type> extends Type = {}; for (const [name, value] of Object.entries(data)) { // Fall back to any — deeper inference is the describer's job, not ours. - const inferred = (this.registry.any() as Type).describe?.(value) ?? this.registry.any(); + const inferred = this.registry.any().describe?.(value) ?? this.registry.any(); fields[name] = { type: inferred }; } return new ObjType(this.registry, fields); diff --git a/packages/gin/src/types/optional.ts b/packages/gin/src/types/optional.ts index d7724bd0..376b782c 100644 --- a/packages/gin/src/types/optional.ts +++ b/packages/gin/src/types/optional.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue, RuntimeOf } from '../json-type'; @@ -18,11 +19,12 @@ export class OptionalType extends Type extends Type) { - super(registry, {}, { T: inner }); + constructor(scope: TypeScope, readonly inner: Type) { + super(scope, {}, { T: inner }); } - valid(raw: unknown): raw is RuntimeOf { - return raw === undefined || this.inner.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return raw === undefined || this.inner.valid(raw, scope); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { if (json === undefined || json === null) return new Value(this, undefined as RuntimeOf); - const v = this.inner.parse(json); + const v = this.inner.parse(json, scope); return new Value(this, v.raw as RuntimeOf); } - encode(raw: RuntimeOf): JSONOf { + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { if (raw === undefined) return null as JSONOf; - return this.inner.encode(raw as RuntimeOf) as JSONOf; + return this.inner.encode(raw as RuntimeOf, scope) as JSONOf; } create(): RuntimeOf { @@ -71,12 +73,12 @@ export class OptionalType extends Type): Type { @@ -117,7 +119,7 @@ export class OptionalType extends Type extends Type); } - toCode(): string { - return this.docsPrefix() + `optional<${this.inner.toCode()}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `optional<${this.inner.toCode(undefined, options)}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(this.inner.toValueSchema(opts).optional(), opts); } diff --git a/packages/gin/src/types/or.ts b/packages/gin/src/types/or.ts index 910d9608..13c4bb44 100644 --- a/packages/gin/src/types/or.ts +++ b/packages/gin/src/types/or.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { Call, type CompatOptions, GetSet, type Prop, type PropSpec, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface OrOptions { @@ -26,9 +27,10 @@ export class OrType extends Type { static readonly NAME = 'or'; readonly name = OrType.NAME; - static from(json: TypeDef, registry: Registry): OrType { - const variants = ((json.options?.types ?? []) as TypeDef[]).map((t) => registry.parse(t)); - return new OrType(registry, variants); + static from(json: TypeDef, scope: TypeScope): OrType { + const registry = scope.registry; + const variants = ((json.options?.types ?? []) as TypeDef[]).map((t) => scope.parse(t)); + return new OrType(scope, variants); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,22 +44,22 @@ export class OrType extends Type { return opts.Expr; } - constructor(registry: Registry, variants: Type[]) { - super(registry, { variants }); + constructor(scope: TypeScope, variants: Type[]) { + super(scope, { variants }); } get variants(): Type[] { return this.options.variants; } - valid(raw: unknown): raw is any { - return this.variants.some((v) => v.valid(raw)); + valid(raw: unknown, scope?: TypeScope): raw is any { + return this.variants.some((v) => v.valid(raw, scope)); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { for (const v of this.variants) { try { - const parsed = v.parse(json); + const parsed = v.parse(json, scope); return new Value(this, parsed.raw); } catch { continue; @@ -70,15 +72,15 @@ export class OrType extends Type { }); } - encode(raw: any): any { - const match = this.variants.find((v) => v.valid(raw)); + encode(raw: any, scope?: TypeScope): any { + const match = this.variants.find((v) => v.valid(raw, scope)); if (!match) { throw new TypeError({ path: [], code: 'or.dump.no-match', message: 'or.dump: value does not satisfy any variant', severity: 'error', }); } - return match.encode(raw); + return match.encode(raw, scope); } create(): any { @@ -100,12 +102,12 @@ export class OrType extends Type { return this.registry.or(narrowed); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { // other is assignable to Or iff it's assignable to at least one variant. if (other instanceof OrType) { - return other.variants.every((v) => this.compatible(v, opts)); + return other.variants.every((v) => this.compatible(v, opts, scope)); } - return this.variants.some((v) => v.compatible(other, opts)); + return this.variants.some((v) => v.compatible(other, opts, scope)); } or(other: Type): Type { @@ -175,11 +177,11 @@ export class OrType extends Type { return new OrType(this.registry, this.variants.map((v) => v.clone())); } - toCode(): string { - return this.docsPrefix() + `or<${this.variants.map((v) => v.toCode()).join(', ')}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `or<${this.variants.map((v) => v.toCode(undefined, options)).join(', ')}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { if (this.variants.length === 0) return this.describeType(z.never(), opts); if (this.variants.length === 1) return this.describeType(this.variants[0]!.toValueSchema(opts), opts); const schemas = this.variants.map((v) => v.toValueSchema(opts)) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]; diff --git a/packages/gin/src/types/ref.ts b/packages/gin/src/types/ref.ts deleted file mode 100644 index e176d206..00000000 --- a/packages/gin/src/types/ref.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { Registry } from '../registry'; -import type { PathStepDef, TypeDef } from '../schema'; -import { Value } from '../value'; -import { - type Call, - type CompatOptions, - type GetSet, - type Init, - type Prop, - type PropSpec, - type Rnd, - Type, -} from '../type'; -import { TypeError } from '../problem'; -import { z } from 'zod'; -import type { SchemaOptions } from '../node'; - - -export interface RefOptions { - name: string; -} - -/** - * RefType — a lazy reference to a named type registered in the Registry. - * All methods delegate to the resolved target. Used for forward references - * and for breaking potentially-cyclic type definitions. - */ -export class RefType extends Type { - static readonly NAME = 'ref'; - readonly name = RefType.NAME; - - static from(json: TypeDef, registry: Registry): RefType { - const name = (json.options?.name ?? '') as string; - return new RefType(registry, { name }); - } - - static toSchema(opts: SchemaOptions): z.ZodTypeAny { - return z.object({ - name: z.literal('ref'), - options: z.object({ name: z.string() }), - }).meta({ aid: 'Type_ref' }); - } - - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.any(); } - - private resolve(): Type { - const target = this.registry.lookup(this.options.name); - if (!target) { - throw new TypeError({ - path: [], code: 'ref.unresolved', - message: `ref.${this.options.name}: not registered`, severity: 'error', - }); - } - return target; - } - - valid(raw: unknown): raw is any { - return this.resolve().valid(raw); - } - - parse(json: unknown): Value { - const v = this.resolve().parse(json); - return new Value(this, v.raw); - } - - encode(raw: any): any { - return this.resolve().encode(raw); - } - - create(): any { - return this.resolve().create(); - } - - random(rnd: Rnd): any { - return this.resolve().random(rnd); - } - - compatible(other: Type, opts?: CompatOptions): boolean { - return this.resolve().compatible(other, opts); - } - - flexible(): boolean { - return true; - } - - or(other: Type): Type { - return this.resolve().or(other); - } - - simplify(): Type { - return this.resolve(); - } - - narrow(local: Partial): RefOptions { - if (local.name && local.name !== this.options.name) { - throw new TypeError({ - path: [], code: 'ref.rename', - message: 'ref name cannot change via narrow', severity: 'error', - }); - } - return this.options; - } - - props(): Record { - // When the ref resolves, the target's props already include the - // universal `toAny` via base.Type.props. If it can't resolve yet - // (unregistered target), fall back to the universal-only set. - try { - return this.resolve().props(); - } catch { - return super.props(); - } - } - - get(): GetSet | undefined { - try { return this.resolve().get(); } catch { return undefined; } - } - - call(): Call | undefined { - try { return this.resolve().call(); } catch { return undefined; } - } - - init(): Init | undefined { - try { return this.resolve().init(); } catch { return undefined; } - } - - follow(step: PathStepDef): Type | undefined { - return this.resolve().follow(step); - } - - toJSON(): TypeDef { - return { - name: RefType.NAME, - options: { name: this.options.name }, - }; - } - - clone(): RefType { - return new RefType(this.registry, { name: this.options.name }); - } - - toCode(): string { return this.docsPrefix() + this.options.name; } - - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { - // Lazy so recursive named types (A → list) don't blow the stack. - return this.describeType(z.lazy(() => this.resolve().toValueSchema(opts)), opts); - } - - toNewSchema(opts: SchemaOptions): z.ZodTypeAny { - return this.describeType(z.lazy(() => this.resolve().toNewSchema(opts)), opts, 'NewValue_'); - } - - /** A ref IS a name — the instance schema is just `{name: }`. Lazy - * so self-referential named types (A → list) don't infinite-recurse. */ - toInstanceSchema(): z.ZodTypeAny { - return z.lazy(() => this.resolve().toInstanceSchema()); - } -} diff --git a/packages/gin/src/types/text.ts b/packages/gin/src/types/text.ts index 0f66fa14..185ed6cd 100644 --- a/packages/gin/src/types/text.ts +++ b/packages/gin/src/types/text.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } fr import type { TextOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -23,19 +24,20 @@ export class TextType extends Type { private _regex?: RegExp; - static from(json: TypeDef, registry: Registry): TextType { - return new TextType(registry, (json.options ?? {}) as TextOptions); + static from(json: TypeDef, scope: TypeScope): TextType { + const registry = scope.registry; + return new TextType(scope, (json.options ?? {}) as TextOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ name: z.literal('text'), options: z.object({ - minLength: z.number().optional(), - maxLength: z.number().optional(), - pattern: z.string().optional(), - flags: z.string().optional(), - }).optional(), + minLength: z.number().optional().describe('Only set when a real lower bound is part of the spec — e.g. "non-empty input" → minLength: 1. Do NOT default to 0.'), + maxLength: z.number().optional().describe('Only set when there is an actual upper bound — a database column width, an API limit, an explicit "no longer than X chars" rule. Do NOT pick a generic ceiling like 100/200/1000 just to fill the field.'), + pattern: z.string().optional().describe('Only set for actual format constraints (UUID, ISO date, slug, etc.). Do NOT use ".*" or other accept-anything patterns — they add zero validation and clutter the schema.'), + flags: z.string().optional().describe('Regex flags (e.g. "i"). Omit unless `pattern` is set AND requires flags.'), + }).optional().describe('Omit entirely for ordinary strings. Only include when the value has a real, named constraint worth enforcing on every parse.'), }).meta({ aid: 'Type_text' }); } @@ -206,9 +208,9 @@ export class TextType extends Type { return new TextType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'text' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'text' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = z.string(); if (this.options.minLength !== undefined) s = s.min(this.options.minLength); if (this.options.maxLength !== undefined) s = s.max(this.options.maxLength); diff --git a/packages/gin/src/types/timestamp.ts b/packages/gin/src/types/timestamp.ts index c858904b..663fd93b 100644 --- a/packages/gin/src/types/timestamp.ts +++ b/packages/gin/src/types/timestamp.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; @@ -5,7 +6,7 @@ import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../t import type { TimestampOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -20,8 +21,9 @@ export class TimestampType extends Type { static readonly NAME = 'timestamp'; readonly name = TimestampType.NAME; - static from(json: TypeDef, registry: Registry): TimestampType { - return new TimestampType(registry, (json.options ?? {}) as TimestampOptions); + static from(json: TypeDef, scope: TypeScope): TimestampType { + const registry = scope.registry; + return new TimestampType(scope, (json.options ?? {}) as TimestampOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -121,9 +123,9 @@ export class TimestampType extends Type { return new TimestampType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'timestamp' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'timestamp' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is ISO 8601 datetime with a REQUIRED time component: // YYYY-MM-DD[T or space]HH:MM[:SS[.fff]][Z|±HH:MM] return this.describeType(z.string().regex( diff --git a/packages/gin/src/types/tuple.ts b/packages/gin/src/types/tuple.ts index a536df89..c4be4fb3 100644 --- a/packages/gin/src/types/tuple.ts +++ b/packages/gin/src/types/tuple.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { PathStepDef, TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONValue } from '../json-type'; @@ -23,10 +24,11 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { readonly elements: Type[]; - static from(json: TypeDef, registry: Registry): TupleType { + static from(json: TypeDef, scope: TypeScope): TupleType { + const registry = scope.registry; const defs = ((json.options?.elements ?? []) as TypeDef[]); - const elems = defs.map((d) => registry.parse(d)); - return new TupleType(registry, elems); + const elems = defs.map((d) => scope.parse(d)); + return new TupleType(scope, elems); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -43,18 +45,18 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return z.array(opts.Expr); } - constructor(registry: Registry, elements: Type[]) { - super(registry, { elements: elements.map((e) => e.toJSON()) }); + constructor(scope: TypeScope, elements: Type[]) { + super(scope, { elements: elements.map((e) => e.toJSON()) }); this.elements = elements; } - valid(raw: unknown): raw is [Value, ...Value[]] { + valid(raw: unknown, scope?: TypeScope): raw is [Value, ...Value[]] { if (!Array.isArray(raw)) return false; if (raw.length !== this.elements.length) return false; - return raw.every((v) => v instanceof Value && v.type.valid(v.raw)); + return raw.every((v) => v instanceof Value && v.type.valid(v.raw, scope)); } - parse(json: unknown): Value<[any, ...any[]]> { + parse(json: unknown, scope?: TypeScope): Value<[any, ...any[]]> { if (!Array.isArray(json) || json.length !== this.elements.length) { throw new TypeError({ path: [], code: 'tuple.invalid', @@ -62,12 +64,12 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { severity: 'error', }); } - const raw = this.elements.map((e, i) => this.registry.parseValue(json[i], e)) as [Value, ...Value[]]; + const raw = this.elements.map((e, i) => this.registry.parseValue(json[i], e, scope)) as [Value, ...Value[]]; return new Value(this, raw); } /** Each positional value becomes a `JSONValue` envelope. */ - encode(raw: [Value, ...Value[]]): [JSONValue, ...JSONValue[]] { + encode(raw: [Value, ...Value[]], _scope?: TypeScope): [JSONValue, ...JSONValue[]] { return raw.map((v) => v.toJSON()) as [JSONValue, ...JSONValue[]]; } @@ -88,10 +90,10 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return this.registry.tuple(narrowed); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof TupleType)) return false; if (other.elements.length !== this.elements.length) return false; - return this.elements.every((e, i) => e.compatible(other.elements[i]!, opts)); + return this.elements.every((e, i) => e.compatible(other.elements[i]!, opts, scope)); } or(other: Type<[any, ...any[]]>): Type<[any, ...any[]]> { @@ -127,7 +129,7 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { }); } - follow(step: PathStepDef): Type | undefined { + follow(step: PathStepDef, scope?: TypeScope): Type | undefined { // Literal positional index → exact element type. if ('key' in step && !('args' in step)) { const key = step.key as any; @@ -136,7 +138,7 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return this.elements[rawKey]; } } - return super.follow(step); + return super.follow(step, scope); } props(): Record { @@ -166,11 +168,11 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return new TupleType(this.registry, this.elements.map((e) => e.clone())); } - toCode(): string { - return this.docsPrefix() + `tuple<${this.elements.map((e) => e.toCode()).join(', ')}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `tuple<${this.elements.map((e) => e.toCode(undefined, options)).join(', ')}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Tuples ARE positional by nature — emit z.tuple for fidelity. LLM // consumers that struggle with positional arrays should use a different // shape (obj with named fields); tuple type preserves the position diff --git a/packages/gin/src/types/typ.ts b/packages/gin/src/types/typ.ts index 1949539c..9f152964 100644 --- a/packages/gin/src/types/typ.ts +++ b/packages/gin/src/types/typ.ts @@ -1,7 +1,8 @@ -import { z } from 'zod'; +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; +import { z } from 'zod'; import type { TypeDef } from '../schema'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { Value } from '../value'; import { extensionSchemaNarrowed } from '../schemas'; @@ -25,9 +26,10 @@ export class TypType extends Type> { static readonly NAME = 'typ'; readonly name = TypType.NAME; - static from(json: TypeDef, registry: Registry): TypType { - const constraint = json.generic?.T ? registry.parse(json.generic.T) : registry.any(); - return new TypType(registry, constraint); + static from(json: TypeDef, scope: TypeScope): TypType { + const registry = scope.registry; + const constraint = json.generic?.T ? scope.parse(json.generic.T) : registry.any(); + return new TypType(scope, constraint); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,29 +44,30 @@ export class TypType extends Type> { return opts.Type; } - constructor(registry: Registry, readonly constraint: Type) { - super(registry, {}, { T: constraint }); + constructor(scope: TypeScope, readonly constraint: Type) { + super(scope, {}, { T: constraint }); } /** Accepts a Type instance whose values fit (in either direction — see * note on compat asymmetry). The raw IS a Type, not JSON. */ - valid(raw: unknown): boolean { + valid(raw: unknown, scope?: TypeScope): boolean { if (!(raw instanceof Type)) return false; // Accept in either direction: `raw.compatible(constraint)` handles // Extension subtypes (Positive.compatible(num) = true via base); the // opposite direction `constraint.compatible(raw)` handles top-type // cases (any.compatible(num) = true) so `typ` accepts everything. - return raw.compatible(this.constraint) || this.constraint.compatible(raw); + return raw.compatible(this.constraint, undefined, scope) + || this.constraint.compatible(raw, undefined, scope); } /** Parse a JSON TypeDef into a Type instance, then validate against the * constraint. One-shot conversion — subsequent `.raw` access is free. */ - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { // Passthrough: already a Value of the right shape. if (json instanceof Value && json.type instanceof TypType) return json; // Already a Type — wrap directly. if (json instanceof Type) { - if (!this.valid(json)) { + if (!this.valid(json, scope)) { throw new Error(`typ.parse: Type '${json.name}' is not compatible with ${this.constraint.toCode()}`); } return new Value(this, json); @@ -78,19 +81,19 @@ export class TypType extends Type> { } let parsed: Type; try { - parsed = this.registry.parse(json as TypeDef); + parsed = this.registry.parse(json as TypeDef, scope); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`typ.parse: ${msg}`); } - if (!this.valid(parsed)) { + if (!this.valid(parsed, scope)) { throw new Error(`typ.parse: Type '${parsed.name}' is not compatible with ${this.constraint.toCode()}`); } return new Value(this, parsed); } /** Serialize to TypeDef JSON — the wire form. */ - encode(raw: Type): TypeDef { + encode(raw: Type, _scope?: TypeScope): TypeDef { return raw.toJSON(); } @@ -102,9 +105,9 @@ export class TypType extends Type> { return this.constraint; } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof TypType)) return false; - return this.constraint.compatible(other.constraint, opts); + return this.constraint.compatible(other.constraint, opts, scope); } or(other: Type): Type { @@ -133,11 +136,11 @@ export class TypType extends Type> { return new TypType(this.registry, this.constraint.clone() as Type); } - toCode(): string { - return this.docsPrefix() + `typ<${this.constraint.toCode()}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `typ<${this.constraint.toCode(undefined, options)}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const narrowed = this.registry.like(this.constraint); // If no registry type is compatible with the constraint, nothing can // pass — emit never so callers can't supply an arbitrary TypeDef. @@ -150,8 +153,12 @@ export class TypType extends Type> { const compatibleNames = this.registry .compatible(this.constraint) .map((t) => t.name); - const inlineExt = opts && compatibleNames.length > 0 - ? extensionSchemaNarrowed(this.registry, opts, compatibleNames) + // `extensionSchemaNarrowed` needs the full meta-language schema bag + // (`Type` + `Expr`). When toValueSchema is called with only the + // narrow ValueSchemaOptions, gracefully drop the inline-extension + // branch — the base instance schema is still correct. + const inlineExt = opts?.Type && opts?.Expr && compatibleNames.length > 0 + ? extensionSchemaNarrowed(this.registry, opts as SchemaOptions, compatibleNames) : null; const schema = inlineExt diff --git a/packages/gin/src/types/void.ts b/packages/gin/src/types/void.ts index de123bca..b424d60c 100644 --- a/packages/gin/src/types/void.ts +++ b/packages/gin/src/types/void.ts @@ -1,10 +1,11 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -15,8 +16,9 @@ export class VoidType extends Type> { static readonly NAME = 'void'; readonly name = VoidType.NAME; - static from(_json: TypeDef, registry: Registry): VoidType { - return new VoidType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): VoidType { + const registry = scope.registry; + return new VoidType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { @@ -87,9 +89,9 @@ export class VoidType extends Type> { return new VoidType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'void'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'void'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } toInstanceSchema(): z.ZodTypeAny { return z.object({ name: z.literal('void') }).passthrough(); diff --git a/packages/ginny/LICENSE b/packages/ginny/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/packages/ginny/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/ginny/README.md b/packages/ginny/README.md index 851084bb..f09aa147 100644 --- a/packages/ginny/README.md +++ b/packages/ginny/README.md @@ -24,6 +24,12 @@ Everything is typed end-to-end. Every write/test/finish cycle happens inside gin's type system — the agent can't produce invalid expressions, and the structured output you get back carries full type information. +For complex requests (multiple types and functions, ambiguous scope, +"build me a small system…") the programmer pauses to ask clarifying +questions, writes a plan listing the types/fns/vars it intends to +create, and waits for your approval before any code is written. Simple +requests like "add 2 and 3" skip the dance. + ## First run ```bash @@ -31,7 +37,7 @@ $ cd my-new-project $ ginny Created /path/to/my-new-project/config.json -Added config.json to .gitignore +Added config.json + ginny.log to .gitignore Populate the file before re-running: At least one AI provider: @@ -44,6 +50,7 @@ Populate the file before re-running: GIN_PROVIDER — optional, preferred provider (openai | openrouter | aws) GIN_MODEL — optional, specific model id GIN_SEARCH_THRESHOLD — optional, corpus size below which search returns all (default 20) + GIN_TOOL_ITERATIONS — optional, max tool-call iterations per prompt run (default 100) Environment variables still win over config.json values. ``` @@ -70,7 +77,7 @@ ginny is a small council of sub-agents, each specialized: ┌────────────────┬──────┴──────┬────────────────┐ ▼ ▼ ▼ ▼ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │ architect │ │ engineer │ │ dba │ │ researcher │ + │ architect │ │ designer │ │ dba │ │ researcher │ │ (types) │ │ (fns) │ │ (vars) │ │ (web search │ │ │ │ │ │ │ │ + pages) │ └─────────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘ @@ -80,32 +87,40 @@ ginny is a small council of sub-agents, each specialized: ``` - **programmer** — writes a gin `ExprDef`, calls `test()` against it, - and calls `finish()` when a test passes. Has `write / test / finish` - build tools plus the find-or-create tools for pulling in catalog - items, and a `research` tool for factual lookups. -- **architect** — searches `./types/*.json` by keyword (top-10 above a - configurable threshold, or all entries below); returns existing - types or designs new ones. -- **engineer** — same pattern over `./fns/*.json`; can recursively - spin up the programmer to implement a brand-new function body. + and `finish()` when a test passes. Has the build tools + (`write` / `test` / `finish`), the find-or-create tools for pulling + in catalog items, an `edit_type` tool for backwards-compatible type + edits, and a `research` tool for factual lookups. +- **architect** — searches `./types/*.json` by keyword (top-N when the + catalog grows past the threshold, or all entries below); returns + existing types or designs new ones. +- **designer** — same pattern over `./fns/*.json`. Has both + `create_new_fn` (new function from scratch — recursively spawns a + programmer to author the body) and `edit_fn` (backwards-compatible + signature change + fresh body). The compat checker accepts widening + edits and rejects breaking ones. - **dba** — same pattern over `./vars/*.json` (typed named values the user or agent can read/write). - **researcher** — wraps `web_search` + `web_get_page`; answers a - natural-language question iteratively and returns `{ answer, sources }`. + natural-language question iteratively and returns + `{ answer, sources }`. ## Persistence Every catalog entry is one JSON file per name. The filename IS the -identity. All four directories are relative to your current working +identity. The three directories are relative to your current working directory: ``` ./types/Task.json # the Task type ./fns/factorial.json # the factorial function ./vars/apiBaseUrl.json # a persistent var (type + value + docs) -./programs/.json # finalized programs from past requests ``` +You can hand-edit any of these between sessions. The next run picks up +your changes. Drop a new file into any of the three directories by +hand and ginny discovers it on the next search. + ### Example: `./vars/apiBaseUrl.json` A var is a `{type, value, docs}` triple — the simplest on-disk shape: @@ -124,26 +139,39 @@ Loaded at use time, `vars.apiBaseUrl` shows up in scope as a typed ### Types and functions `./types/.json` is a `TypeDef` — gin's serialized type -descriptor. `./fns/.json` is a `{type, body}` pair where `type` -is a `function` TypeDef and `body` is an `ExprDef`. See the -[gin README](../gin#core-concepts) for what TypeDef and ExprDef look -like. +descriptor. `./fns/.json` is a `function`-typed `TypeDef` whose +body lives at `call.get` (gin's native callable shape — see +[gin/src/path.ts](../gin/src/path.ts) for how the path walker +dispatches). The top-level `docs` field is the function's description. -You can hand-edit any of these between sessions. The next run picks up -your changes. Drop a new file into any of the four directories by hand -and ginny discovers it on the next search. +See the [gin README](../gin#core-concepts) for what TypeDef and ExprDef +look like. ## Built-in globals Programs always have access to: -- **`fns.fetch({ url, method?, headers?, body?, output?: typ }): R`** +- **`fns.fetch({ url, method?, headers?, body?, output?: typ }): R`** HTTP fetch. When `output` is a gin Type, the response body is parsed through it — type-safe HTTP in one call. -- **`fns.llm({ prompt, tools?, output?: typ }): R`** - LLM invocation. Pass a gin Type as `output` to get structured, - typed output. +- **`fns.llm({ prompt, tools?, output?: typ }): R`** + LLM invocation. Pass a gin Type as `output` to get structured, typed + output. The `` constraint says R must be either a + text-shaped reply or an obj-shaped structured output — chosen at the + call site. + +- **`fns.log({ message: any }): void`** + Print a runtime message to the user (stderr). Use for progress + narration, intermediate values, or debug breadcrumbs. Distinct from + the program's return value. + +- **`fns.ask({ title: text, details: text, output?: typ }): optional`** + Pause the program and prompt the user. With `output` set the + consumer walks the user through any complex shape (obj fields, list + items, choices, optionals) — every (sub)type's `docs` field becomes + the user-facing label. Returns `null` (`optional`) if the user + cancels, so the program must handle that branch explicitly. - **`vars.`** — any var you've created or imported. @@ -153,7 +181,7 @@ Programs always have access to: > compute the factorial of 6 • (programmer calls find_or_create_functions "factorial function") -• (fn designer spins up new programmer → writes recursive gin program) +• (designer spins up a fresh programmer → writes the recursive gin program) • (programmer calls write(program)) • (programmer calls test() → SUCCESS: 720) • (programmer calls finish()) @@ -161,8 +189,31 @@ Programs always have access to: 720 ``` -The programmer can set `expectError: true` on `test()` to verify a -program raises — useful for "divide 1 by 0 and tell me what happens". +`test()` runs the draft program against sample args. The programmer +can set `expectError: true` to verify a program raises — useful for +"divide 1 by 0 and tell me what happens". + +`finish()` accepts an optional `saveAs: ''` to persist +the program as a reusable function — every saved fn becomes a +callable global, so subsequent runs can invoke it directly. + +## Editing existing types and functions + +Two tools cover backwards-compatible edits: + +- **`edit_type({ name, def })`** (programmer) — replace a saved type's + definition. Allowed: add OPTIONAL fields, widen existing field + types, loosen constraints. Rejected: remove fields, add required + fields, narrow field types, change the type class. +- **`edit_fn({ name, args, returns, body })`** (designer) — change a + saved function's signature and body. Args may add optional params + or widen existing param types; returns may NARROW. The body is + rewritten from scratch by an inner programmer. + +Both tools enforce backwards-compat at parse time and reject breaking +changes with a structured error. If a change is genuinely incompatible +the right move is usually to create a new type / fn under a different +name so existing callers keep working. ## Configuration @@ -176,8 +227,15 @@ directory, or from environment variables (env wins on conflict): | `AWS_REGION` | region for AWS Bedrock (default `us-east-1`) | | `TAVILY_API_KEY` | enables the `web_search` tool | | `GIN_PROVIDER` | preferred provider (openai \| openrouter \| aws) | -| `GIN_MODEL` | pin a specific model id | +| `GIN_MODEL` | pin a specific model id (fallback for any sub-agent without an override) | +| `GIN_PROGRAMMER_MODEL` | model id for the programmer sub-agent | +| `GIN_DESIGNER_MODEL` | model id for the designer (fns) sub-agent | +| `GIN_ARCHITECT_MODEL` | model id for the architect (types) sub-agent | +| `GIN_DBA_MODEL` | model id for the dba (vars) sub-agent | +| `GIN_RESEARCHER_MODEL` | model id for the researcher sub-agent | +| `GIN_LLM_MODEL` | model id for the in-program `fns.llm` calls | | `GIN_SEARCH_THRESHOLD` | corpus size below which catalog search returns all entries (default 20) | +| `GIN_TOOL_ITERATIONS` | max tool-call iterations per prompt run (default 100) | ### AWS Bedrock @@ -202,6 +260,15 @@ ginny: providers enabled → openai, aws + web_search (tavily) At least one provider must resolve. Tavily is optional — without it the programmer still has `web_get_page` (fetch + strip HTML). +## Logging + +Each session writes a verbose timeline to `./ginny.log` (truncated on +startup). Tool inputs and outputs, full validation problems, full +zod parse errors, and stack traces all land in the log; the terminal +view stays compact (one line per error, capped at 200–4096 chars +depending on context). When something goes sideways, `ginny.log` is +where to look. + ## Example sessions ``` @@ -215,10 +282,13 @@ the programmer still has `web_get_page` (fetch + strip HTML). → programmer reads vars.apiBaseUrl, returns the string. > define a Task type with title, done, due - → type designer creates ./types/Task.json (extending obj with props). + → architect creates ./types/Task.json (extending obj with props). > create a program that counts done tasks from a list of tasks → programmer emits a list.filter + .length program using Task. + +> add an `assignee` field to Task (optional) + → programmer calls edit_type — backwards-compatible widening accepted. ``` ## Building from source @@ -247,8 +317,8 @@ provides: ginny provides: - the AI wiring (provider selection, model override, per-request context) -- the sub-agent orchestration (type / fn / vars designers, programmer) -- the CWD-relative catalog (types / fns / vars / programs directories) +- the sub-agent orchestration (architect / designer / dba / researcher / programmer) +- the CWD-relative catalog (types / fns / vars directories) - the REPL and one-shot CLI entry point If you want to embed the same capabilities in your own application diff --git a/packages/ginny/esbuild.config.cjs b/packages/ginny/esbuild.config.cjs index 26153b77..5ca3dc51 100644 --- a/packages/ginny/esbuild.config.cjs +++ b/packages/ginny/esbuild.config.cjs @@ -24,9 +24,34 @@ const esmBanner = { import { createRequire as __createRequire } from 'module'; import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __dirname_func } from 'path'; +import { setMaxListeners as __setMaxListeners } from 'events'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __dirname_func(__filename); const require = __createRequire(import.meta.url); + +// Lift the AbortSignal listener cap before ANY module's top-level code +// runs. \`setMaxListeners(n)\` with no target sets the global default, +// but Node's EventTarget doesn't reliably honour that default across +// versions — and even when it does, that only fixes signals our code +// can see. The cap also fires for AbortSignals created INSIDE the AI +// library / SDKs / built-in fetch, which we never get a handle to. +// +// The bulletproof fix (per cletus: setMaxListeners(Infinity, signal)) +// is to patch \`globalThis.AbortController\` so every signal — ours, +// theirs, fetch's — is uncapped at birth. Banner code runs before any +// import, so SDKs that capture \`globalThis.AbortController\` at module +// init also see the patched constructor. +try { __setMaxListeners(0); } catch { /* runtime mismatch */ } +const __OriginalAbortController = globalThis.AbortController; +class __UncappedAbortController extends __OriginalAbortController { + constructor() { + super(); + try { + __setMaxListeners(Number.POSITIVE_INFINITY, this.signal); + } catch { /* unsupported runtime */ } + } +} +globalThis.AbortController = __UncappedAbortController; `, }; @@ -39,6 +64,12 @@ esbuild.build({ format: 'esm', plugins: [shebangPlugin], banner: esmBanner, + // `puppeteer` is heavy (Chromium download ~170 MB) and only used by + // web fetching. Externalize it so the bundle does a runtime + // `require('puppeteer')` instead of inlining it — paired with + // `optionalDependencies` in package.json, this lets users skip the + // Chromium install entirely if they don't need web fetching. + external: ['puppeteer'], define: { 'process.env.NODE_ENV': '"production"', }, diff --git a/packages/ginny/package.json b/packages/ginny/package.json index 7797856e..bfa64d5b 100644 --- a/packages/ginny/package.json +++ b/packages/ginny/package.json @@ -41,14 +41,16 @@ "mammoth": "^1.8.0", "node-html-markdown": "^1.3.0", "pdf-parse": "^1.1.1", - "puppeteer": "^24.0.0", "xlsx": "^0.18.5" }, + "optionalDependencies": { + "puppeteer": "^24.0.0" + }, "devDependencies": { "@aeye/ai": "0.3.8", "@aeye/aws": "0.3.8", "@aeye/core": "0.3.8", - "@aeye/gin": "0.1.0", + "@aeye/gin": "0.3.8", "@aeye/models": "0.3.8", "@aeye/openai": "0.3.8", "@aeye/openrouter": "0.3.8", diff --git a/packages/ginny/src/ai.ts b/packages/ginny/src/ai.ts index ad2f4e6a..3e290fe0 100644 --- a/packages/ginny/src/ai.ts +++ b/packages/ginny/src/ai.ts @@ -1,81 +1,158 @@ import { loadConfig } from './config'; -import { logger } from './logger'; +import { logger, genId } from './logger'; import { AI, type Provider } from '@aeye/ai'; import { OpenAIProvider } from '@aeye/openai'; import { OpenRouterProvider } from '@aeye/openrouter'; import { AWSBedrockProvider } from '@aeye/aws'; -import { models } from '@aeye/models'; +import { models, strictSupport } from '@aeye/models'; import type { Ctx, Meta } from './context'; import { bootstrap } from './registry'; import { createStore } from './store'; import { createRunState } from './run-state'; import { createFetchImpl, registerFetchType } from './natives/fetch'; import { createLlmImpl, registerLlmType } from './natives/llm'; +import { createLogImpl, registerLogType } from './natives/log'; +import { createAskImpl, registerAskType } from './natives/ask'; +import { MODEL_KEYS } from './model-selection'; // Hydrate process.env from config.json before anything reads env vars. // Safe: imported modules above just declare classes; no env-var reads run yet. loadConfig(process.cwd()); /** - * Provider-level hooks that log the actual wire payload — the params - * object the provider hands to the SDK's API call. This is what OpenAI - * / OpenRouter / AWS actually sees (JSON Schema tools, flattened message - * content, etc.) — drastically more useful for debugging 400s like - * "Invalid schema for function 'write'" than logging the pre-serialized - * internal `Request`. + * Provider-level hooks that log a SUMMARY of the wire payload. We used + * to dump the full `params` and `responseComplete` objects via + * `logger.logObject` — but ginny registers ~20 tools, each with a + * deeply-recursive Zod schema, so a serialized request was easily + * 10–50 MB. Multiplied by the request fan-out a designer makes per + * turn, JSON.stringify ate gigabytes of intermediate strings and V8 + * OOMed. The summary captures the load-bearing fields (model, message + * count, tool count, finish reason, usage) — which is what's + * diagnostic anyway. Set `GIN_LOG_FULL_PAYLOAD=1` if you genuinely + * need the raw request/response (e.g. debugging a 400) and accept + * the memory cost. */ -const openaiChatHooks = { - beforeRequest: (_req: unknown, params: unknown) => { - logger.log('── OPENAI chat beforeRequest ──'); - logger.logObject('params', params); - }, - afterRequest: (_req: unknown, _params: unknown, _response: unknown, responseComplete: unknown) => { - logger.log('── OPENAI chat afterRequest ──'); - logger.logObject('response', responseComplete); - }, - onError: (_req: unknown, params: unknown, error: unknown) => { - logger.log('── OPENAI chat onError ──'); - logger.logObject('params', params); - logger.logObject('error', error); - }, -}; +const LOG_FULL = !!process.env['GIN_LOG_FULL_PAYLOAD']; -const openrouterChatHooks = { - beforeRequest: (_req: unknown, params: unknown) => { - logger.log('── OPENROUTER chat beforeRequest ──'); - logger.logObject('params', params); - }, - afterRequest: (_req: unknown, _params: unknown, _response: unknown, responseComplete: unknown) => { - logger.log('── OPENROUTER chat afterRequest ──'); - logger.logObject('response', responseComplete); - }, - onError: (_req: unknown, params: unknown, error: unknown) => { - logger.log('── OPENROUTER chat onError ──'); - logger.logObject('params', params); - logger.logObject('error', error); - }, -}; +function summarizeParams(params: unknown): string { + const p = params as { model?: string; messages?: unknown[]; tools?: unknown[]; tool_choice?: unknown; response_format?: unknown }; + if (!p || typeof p !== 'object') return String(params); + return [ + p.model ? `model=${p.model}` : null, + p.messages ? `messages=${p.messages.length}` : null, + p.tools ? `tools=${p.tools.length}` : null, + p.response_format ? `response_format=${(p.response_format as { type?: string })?.type ?? 'set'}` : null, + ].filter(Boolean).join(' '); +} -const awsChatHooks = { - beforeRequest: (_req: unknown, params: unknown) => { - logger.log('── AWS chat beforeRequest ──'); - logger.logObject('params', params); - }, - afterRequest: (_req: unknown, _params: unknown, _response: unknown, responseComplete: unknown) => { - logger.log('── AWS chat afterRequest ──'); - logger.logObject('response', responseComplete); - }, -}; +function summarizeResponse(resp: unknown): string { + const r = resp as { choices?: Array<{ finish_reason?: string; message?: { content?: string; tool_calls?: unknown[] } }>; usage?: { total_tokens?: number; prompt_tokens?: number; completion_tokens?: number } }; + if (!r || typeof r !== 'object') return String(resp); + const choice = r.choices?.[0]; + return [ + choice?.finish_reason ? `finish=${choice.finish_reason}` : null, + choice?.message?.tool_calls ? `tool_calls=${choice.message.tool_calls.length}` : null, + typeof choice?.message?.content === 'string' ? `content_len=${choice.message.content.length}` : null, + r.usage ? `tokens=${r.usage.total_tokens ?? '?'} (in=${r.usage.prompt_tokens ?? '?'} out=${r.usage.completion_tokens ?? '?'})` : null, + ].filter(Boolean).join(' '); +} + +function makeChatHooks(provider: string) { + return { + beforeRequest: (_req: unknown, params: unknown) => { + logger.log(`── ${provider} chat beforeRequest ── ${summarizeParams(params)}`); + if (LOG_FULL) logger.logObject('params', params); + // Memory snapshot before every wire-out: history is serialized + // here, so this is where we'd spot conversation-history bloat + // pushing us toward the heap ceiling. + logger.mem(`${provider} beforeRequest`); + // Also measure how many bytes we're about to send — this number + // doesn't show up in process.memoryUsage() because the + // serialization happens inside the provider, but it bounds the + // outgoing-allocation cost. Catches the "history is now N MB" + // case before it's lost in heapTotal. + try { + const sz = JSON.stringify(params).length; + logger.log(`[mem] ${provider} request payload=${(sz / 1024).toFixed(0)}KB`); + } catch { /* params unstringifiable — already logged above */ } + }, + afterRequest: (_req: unknown, _params: unknown, _response: unknown, responseComplete: unknown) => { + logger.log(`── ${provider} chat afterRequest ── ${summarizeResponse(responseComplete)}`); + if (LOG_FULL) logger.logObject('response', responseComplete); + logger.mem(`${provider} afterRequest`); + // Same idea as the request side — measure the bytes we received + // and parsed. A 50MB response is unusual and points at a + // streaming-accumulation bug or a model echoing huge input. + try { + const sz = JSON.stringify(responseComplete).length; + logger.log(`[mem] ${provider} response payload=${(sz / 1024).toFixed(0)}KB`); + } catch { /* response unstringifiable */ } + }, + onError: (_req: unknown, params: unknown, error: unknown) => { + // Errors are rare and load-bearing for diagnosis — log the full + // params + error here regardless of the cap. + logger.log(`── ${provider} chat onError ── ${summarizeParams(params)}`); + logger.logObject('params', params); + logger.logObject('error', error); + logger.mem(`${provider} onError`); + }, + }; +} + +const openaiChatHooks = makeChatHooks('OPENAI'); +const openrouterChatHooks = makeChatHooks('OPENROUTER'); +const awsChatHooks = makeChatHooks('AWS'); -async function buildProviders(): Promise<{ providers: Record; enabled: string[] }> { +/** + * Shared retry-event handlers — every provider that accepts a `retryEvents` + * option uses these so retry attempts (especially 429s) are visible in + * `ginny.log`. Each retry burst gets a 6-char id stamped on every line so a + * single `grep ` recovers the whole sequence: provider, op, attempts, + * timings, and final outcome. + * + * The defaults built into the providers (3 retries, 1s base, exponential + * backoff, jittered, retryable on [0, 429, 500, 503]) handle transient + * rate-limit blips automatically. When the 429 message says "quota" / + * "billing" we annotate the log so you can tell a credit-exhausted error + * from a genuine rate-limit one instantly. + */ +function makeRetryEvents() { + return { + onRetry: (attempt: number, error: Error, delay: number, ctxMeta: { operation: string; provider: string; requestId?: string }) => { + const id = ctxMeta.requestId ?? genId(); + const isQuota = /quota|billing|insufficient/i.test(error.message); + const flavor = isQuota ? 'quota-exhausted (NOT retryable)' : 'transient'; + logger.log(`[${id}] retry attempt=${attempt} provider=${ctxMeta.provider} op=${ctxMeta.operation} flavor=${flavor} delay=${delay}ms err=${error.message}`); + }, + onMaxRetriesExceeded: (attempts: number, lastError: Error, ctxMeta: { operation: string; provider: string; requestId?: string }) => { + const id = ctxMeta.requestId ?? genId(); + logger.log(`[${id}] retry-exhausted attempts=${attempts} provider=${ctxMeta.provider} op=${ctxMeta.operation} err=${lastError.message}`); + }, + onTimeout: (duration: number, ctxMeta: { operation: string; provider: string; requestId?: string }) => { + const id = ctxMeta.requestId ?? genId(); + logger.log(`[${id}] retry-timeout duration=${duration}ms provider=${ctxMeta.provider} op=${ctxMeta.operation}`); + }, + }; +} + +async function buildProviders(): Promise<{ + providers: Record; + enabled: string[]; + skipped: string[]; +}> { const enabled: string[] = []; const skipped: string[] = []; const providers: Record = {}; + const retryEvents = makeRetryEvents(); + if (process.env['OPENAI_API_KEY']) { providers.openai = new OpenAIProvider({ apiKey: process.env['OPENAI_API_KEY']!, hooks: { chat: openaiChatHooks }, + // Defaults are sane (3 retries, 1s base, expo backoff, retry on + // [0, 429, 500, 503]); we just want visibility into when they fire. + retryEvents, }); enabled.push('openai'); } else { @@ -86,6 +163,7 @@ async function buildProviders(): Promise<{ providers: Record; providers.openrouter = new OpenRouterProvider({ apiKey: process.env['OPENROUTER_API_KEY']!, hooks: { chat: openrouterChatHooks }, + retryEvents, }); enabled.push('openrouter'); } else { @@ -121,11 +199,9 @@ async function buildProviders(): Promise<{ providers: Record; ); } - const tavily = process.env['TAVILY_API_KEY'] ? ' + web_search (tavily)' : ''; - console.error(`ginny: providers enabled → ${enabled.join(', ')}${tavily}`); - for (const s of skipped) console.error(` skipped → ${s}`); - - return { providers, enabled }; + // Logging the result is the entry point's job — it lives downstream + // of `console.clear()` and prints the full startup banner there. + return { providers, enabled, skipped }; } export const { registry, engine } = bootstrap(); @@ -139,7 +215,28 @@ const sessionLoadedVars = new Map_MODEL` override. Used by the startup banner — empty set + * means selection falls through to the model registry's defaults. */ +const configuredModels = new Set(); +for (const k of MODEL_KEYS) { + const v = process.env[`GIN_${k.toUpperCase()}_MODEL`]; + if (v && v.trim()) configuredModels.add(v.trim()); +} +const fallback = process.env['GIN_MODEL']; +if (fallback && fallback.trim()) configuredModels.add(fallback.trim()); + +/** Snapshot of provider/model/feature state captured at AI bootstrap. + * The entry point reads this after clearing the screen so the user + * sees a clean startup summary. */ +export const aiInfo = { + providers: enabledProviderNames, + skipped: skippedProviderReasons, + models: configuredModels, + webSearch: !!process.env['TAVILY_API_KEY'], +}; // Model selection picks the top-scored model across every entry in `models`. // If we don't restrict `providers.allow` to the set of providers we actually @@ -161,6 +258,7 @@ export const ai = AI.with() loadedFns: sessionLoadedFns, loadedVars: sessionLoadedVars, runState: createRunState(), + programmerDepth: 0, }, providedContext: async (ctx) => ({ ...ctx, @@ -171,6 +269,11 @@ export const ai = AI.with() providers: providersMeta, } as any, models, + // Curated strict-mode dialect declarations: opts strict-capable model + // families (gpt-4o+, claude 4.5+, gemini 2.0+) into the `'toolsStrict'` + // capability and pins their JSON-Schema dialect so providers emit the + // right wire shape. Without this, every model defaults to lenient. + modelOverrides: [...strictSupport], }) .withHooks({ // AI-level selection hook — captures which model was picked for each @@ -191,16 +294,45 @@ process.on('SIGTERM', () => { logger.close(); process.exit(0); }); // Wire global natives after AI instance is created. const fetchFnType = registerFetchType(registry); const llmFnType = registerLlmType(registry); +const logFnType = registerLogType(registry); +const askFnType = registerAskType(registry); const fnsType = registry.obj({ fetch: { type: fetchFnType }, - llm: { type: llmFnType }, + llm: { type: llmFnType }, + log: { type: logFnType, docs: 'Print a runtime message to the user (stderr). Use for progress narration or surfacing intermediate values.' }, + ask: { type: askFnType, docs: 'Pause execution and prompt the user for input. Pass `output: typ` to get a typed answer; the consumer walks the user through any complex shape (obj fields, list items, choices, etc). Put `docs` on type fields — those become the prompt labels.' }, }); engine.registerGlobal('fns', { type: fnsType, value: { fetch: createFetchImpl(registry), - llm: createLlmImpl(registry, ai), + llm: createLlmImpl(registry, ai), + log: createLogImpl(registry), + ask: createAskImpl(registry), }, }); + +// Leak-hunting probes: each `[mem]` line gets a compact tail like +// ` globals=42 namedTypes=18 sessionFns=12 sessionTypes=7` +// If those counters grow lock-step with heapUsed across turns, they +// (or the data they reference) ARE the retainer. If they're flat +// while heap climbs, the leak is somewhere else (likely AI lib chunk +// retention or sub-agent state). Probes return null when their +// underlying collection isn't introspectable so the line stays +// compact. +logger.addMemProbe(() => { + try { return `globals=${(engine.getGlobals?.() as Map | undefined)?.size ?? '?'}`; } + catch { return null; } +}); +logger.addMemProbe(() => { + try { + const list = (registry as unknown as { namedTypeList?: () => unknown[] }).namedTypeList?.(); + return Array.isArray(list) ? `namedTypes=${list.length}` : null; + } catch { return null; } +}); +logger.addMemProbe(() => { + try { return `sessionFns=${sessionLoadedFns.size} sessionTypes=${sessionLoadedTypes.size} sessionVars=${sessionLoadedVars.size}`; } + catch { return null; } +}); diff --git a/packages/ginny/src/config.ts b/packages/ginny/src/config.ts index df84a10d..0762db78 100644 --- a/packages/ginny/src/config.ts +++ b/packages/ginny/src/config.ts @@ -14,6 +14,7 @@ export interface GinConfig { GIN_MODEL?: string; GIN_PROVIDER?: string; GIN_SEARCH_THRESHOLD?: number; + GIN_TOOL_ITERATIONS?: number; } const TEMPLATE: GinConfig = { @@ -24,6 +25,7 @@ const TEMPLATE: GinConfig = { GIN_MODEL: '', GIN_PROVIDER: '', GIN_SEARCH_THRESHOLD: 20, + GIN_TOOL_ITERATIONS: 100, }; function ensureGitignore(cwd: string): void { @@ -73,6 +75,7 @@ export function loadConfig(cwd: string): void { console.log(' GIN_PROVIDER — optional, preferred provider (openai | openrouter | aws)'); console.log(' GIN_MODEL — optional, specific model id'); console.log(' GIN_SEARCH_THRESHOLD — optional, corpus size below which search returns all (default 20)'); + console.log(' GIN_TOOL_ITERATIONS — optional, max tool-call iterations per prompt run (default 100)'); console.log(''); console.log('Environment variables still win over config.json values.'); process.exit(0); diff --git a/packages/ginny/src/consumer.ts b/packages/ginny/src/consumer.ts new file mode 100644 index 00000000..2316ea2f --- /dev/null +++ b/packages/ginny/src/consumer.ts @@ -0,0 +1,398 @@ +import { + AliasType, + AndType, + AnyType, + BoolType, + ColorType, + DateType, + DurationType, + EnumType, + FnType, + IfaceType, + ListType, + LiteralType, + MapType, + NotType, + NullType, + NullableType, + NumType, + ObjType, + OptionalType, + OrType, + TextType, + TimestampType, + TupleType, + TypType, + Type, + Value, + val, + type Prop, + type Registry, +} from '@aeye/gin'; +import type { Extension } from '@aeye/gin'; + +/** + * Adapter the consumer uses to ask the user for input. A v1 text-only + * implementation simulates `choice`/`confirm` over the same single-line + * prompt; richer terminal UIs can drop in later without touching the + * consumer itself. + * + * Every method returns `null` to signal cancellation (Ctrl-C, blank + * answer at the top level, etc.). `consume` propagates that as `null` + * all the way up — `fns.ask` surfaces it to the program as + * `optional = null`. + */ +export interface AskAdapter { + text(p: { title: string; details?: string; default?: string }): Promise; + choice(p: { title: string; details?: string; options: string[] }): Promise; + confirm(p: { title: string; details?: string; default?: boolean }): Promise; +} + +/** Default adapter — wraps a single-line `ask(question): string` (the + * same shape `ctx.ask` exposes). Simulates choice via "1-N" picks and + * confirm via `(y/n)`. Empty answer is treated as cancellation, except + * inside `confirm` where empty falls back to the default. */ +export function textAdapter( + ask: (question: string, signal?: AbortSignal) => Promise, + signal?: AbortSignal, +): AskAdapter { + const renderHeader = (title: string, details?: string): string => + details ? `${title}\n ${details}` : title; + + return { + async text({ title, details, default: def }) { + const header = renderHeader(title, details); + const prompt = def !== undefined + ? `${header} [${def}]: ` + : `${header}: `; + const answer = await ask(prompt, signal); + const trimmed = answer.trim(); + if (trimmed === '' && def !== undefined) return def; + if (trimmed === '') return null; + return answer; + }, + + async choice({ title, details, options }) { + if (options.length === 0) return null; + if (options.length === 1) return options[0]!; + const header = renderHeader(title, details); + const list = options.map((o, i) => ` ${i + 1}) ${o}`).join('\n'); + const prompt = `${header}\n${list}\nPick (1-${options.length}): `; + const raw = (await ask(prompt, signal)).trim(); + if (raw === '') return null; + // Allow either the index or the literal label. + const n = Number(raw); + if (Number.isInteger(n) && n >= 1 && n <= options.length) { + return options[n - 1]!; + } + const match = options.find((o) => o === raw); + return match ?? null; + }, + + async confirm({ title, details, default: def }) { + const header = renderHeader(title, details); + const hint = def === true ? '(Y/n)' : def === false ? '(y/N)' : '(y/n)'; + const raw = (await ask(`${header} ${hint}: `, signal)).trim().toLowerCase(); + if (raw === '') return def ?? false; + return raw === 'y' || raw === 'yes' || raw === '1' || raw === 'true'; + }, + }; +} + +/** Caller-supplied prompt context for the top-level `consume` call. */ +export interface ConsumeOptions { + /** Headline shown before the first prompt. */ + title: string; + /** Optional supplemental description. */ + details?: string; +} + +/** + * Walk a `Type` interactively, prompting the user for whatever pieces + * the type needs and returning a parsed `Value` matching that type. + * + * Returns `null` when the user cancels at any depth — the cancellation + * unwinds the whole walk so the caller sees a single `null`. Sub-walks + * use the type's `docs` field (or a prop's `docs` for obj fields) as + * the prompt details, with the type name / field name as the title; + * authors are encouraged to put short user-facing labels in `docs`. + * + * The walker is type-class-driven (instanceof on each gin Type + * subclass) — adding a new Type means adding one more branch here. + */ +export async function consume( + type: Type, + opts: ConsumeOptions, + adapter: AskAdapter, + registry: Registry, +): Promise { + // Resolve aliases / extensions before dispatching so the structural + // form drives the flow. + const t = unwrap(type); + + // Leaf-text-like — read a string, parse via the type. Re-prompt up + // to 3 times on parse error. + if (t instanceof TextType + || t instanceof NumType + || t instanceof DateType + || t instanceof TimestampType + || t instanceof DurationType + || t instanceof ColorType) { + return promptLeaf(t, opts, adapter, 3); + } + + if (t instanceof BoolType) { + const ans = await adapter.confirm({ title: opts.title, details: detailsFor(opts.details, t) }); + return val(registry.bool(), ans); + } + + if (t instanceof NullType) return val(registry.null(), null); + if (t instanceof Type && t.name === 'void') return val(registry.void(), undefined); + if (t instanceof AnyType) { + const raw = await adapter.text({ title: opts.title, details: detailsFor(opts.details, t) }); + if (raw === null) return null; + // Try JSON, fall back to the literal string. + try { return val(registry.any(), JSON.parse(raw)); } + catch { return val(registry.any(), raw); } + } + + if (t instanceof EnumType) { + const opt = t as { options: { values: Record } }; + const labels = Object.keys(opt.options.values); + const picked = await adapter.choice({ + title: opts.title, + details: detailsFor(opts.details, t), + options: labels, + }); + if (picked === null) return null; + const value = opt.options.values[picked]; + return t.parse(value); + } + + if (t instanceof LiteralType) { + // Literal — there's only one valid value; no prompt. + return val(t, (t as unknown as { literal: unknown }).literal); + } + + if (t instanceof OptionalType || t instanceof NullableType) { + const inner = (t as unknown as { inner: Type }).inner; + const give = await adapter.confirm({ + title: `${opts.title} — provide a value?`, + details: detailsFor(opts.details, t), + }); + if (!give) { + return t instanceof OptionalType + ? val(t, undefined) + : val(t, null); + } + const inner_value = await consume(inner, { title: opts.title, details: opts.details }, adapter, registry); + if (inner_value === null) return null; + return val(t, inner_value.raw); + } + + if (t instanceof ListType) { + const item = (t as unknown as { item: Type }).item; + const itemOpts = (t as unknown as { options: { minLength?: number; maxLength?: number } }).options; + const min = itemOpts.minLength ?? 0; + const max = itemOpts.maxLength ?? Infinity; + const items: Value[] = []; + while (items.length < max) { + // Below min: don't even ask, just keep going. + // At-or-above min: confirm whether to add another. + if (items.length >= min) { + const more = await adapter.confirm({ + title: `${opts.title} — add ${items.length === 0 ? 'an' : 'another'} item?`, + details: detailsFor(opts.details, t), + default: items.length < min, + }); + if (!more) break; + } + const itemTitle = `${opts.title}[${items.length}]`; + const itemValue = await consume(item, { title: itemTitle, details: docsFor(item) }, adapter, registry); + if (itemValue === null) return null; + items.push(itemValue); + } + return val(t, items); + } + + if (t instanceof TupleType) { + const elems = (t as unknown as { elements: Type[] }).elements; + const out: Value[] = []; + for (let i = 0; i < elems.length; i++) { + const elem = elems[i]!; + const v = await consume(elem, { + title: `${opts.title}[${i}]`, + details: docsFor(elem), + }, adapter, registry); + if (v === null) return null; + out.push(v); + } + return val(t, out as [Value, ...Value[]]); + } + + if (t instanceof ObjType || t instanceof IfaceType) { + const fields = (t instanceof ObjType + ? (t as unknown as { fields: Record }).fields + : (t as unknown as { _props: Record })._props); + const out: Record = {}; + for (const [name, prop] of Object.entries(fields)) { + const v = await consume(prop.type, { + title: `${opts.title}.${name}`, + // Prop docs win over type docs — they describe THIS field's role + // in the parent shape, which is more specific. + details: prop.docs ?? docsFor(prop.type), + }, adapter, registry); + if (v === null) return null; + out[name] = v; + } + return val(t, out); + } + + if (t instanceof MapType) { + const keyT = (t as unknown as { key: Type }).key; + const valT = (t as unknown as { value: Type }).value; + const m = new Map(); + while (true) { + const more = await adapter.confirm({ + title: `${opts.title} — add ${m.size === 0 ? 'an' : 'another'} entry?`, + details: detailsFor(opts.details, t), + }); + if (!more) break; + const k = await consume(keyT, { title: `${opts.title} key`, details: docsFor(keyT) }, adapter, registry); + if (k === null) return null; + const v = await consume(valT, { title: `${opts.title} value`, details: docsFor(valT) }, adapter, registry); + if (v === null) return null; + m.set(k.raw, [k, v]); + } + return val(t, m); + } + + if (t instanceof OrType) { + const variants = (t as unknown as { variants: Type[] }).variants; + const labels = variants.map((v) => v.toCode()); + const picked = await adapter.choice({ + title: `${opts.title} — pick a variant`, + details: detailsFor(opts.details, t), + options: labels, + }); + if (picked === null) return null; + const idx = labels.indexOf(picked); + const variant = variants[idx]!; + const inner = await consume(variant, { title: opts.title, details: docsFor(variant) }, adapter, registry); + if (inner === null) return null; + return val(t, inner.raw); + } + + if (t instanceof AndType) { + // Intersection — usually structurally equal to the first part. If + // there's a more nuanced merge needed, the LLM should declare an + // Extension instead. + const parts = (t as unknown as { parts: Type[] }).parts; + const first = parts[0]; + if (!first) return val(t, null); + return consume(first, opts, adapter, registry); + } + + if (t instanceof NotType) { + // Can't generate a "not-X" UI. Fall back to text + permissive parse. + const raw = await adapter.text({ title: opts.title, details: detailsFor(opts.details, t) }); + if (raw === null) return null; + return val(t, raw); + } + + if (t instanceof TypType) { + // v1: pick a registered type by name. Inline-Extension authoring + // is out of scope. + const names = registry.namedTypeList().map((nt) => nt.name); + const builtins = registry.typeClasses().map((c) => c.NAME); + const all = Array.from(new Set([...names, ...builtins])).sort(); + const picked = await adapter.choice({ + title: `${opts.title} — pick a type`, + details: detailsFor(opts.details, t), + options: all, + }); + if (picked === null) return null; + return t.parse({ name: picked }); + } + + if (t instanceof FnType) { + throw new Error(`fns.ask: cannot prompt for a function type — ${t.toCode()}`); + } + + // Unknown leaf. Best effort: text + parse. + return promptLeaf(t, opts, adapter, 3); +} + +/** Read a string, parse via the type. Re-prompt up to `maxAttempts` + * times on parse error, surfacing the parser's message. */ +async function promptLeaf( + t: Type, + opts: ConsumeOptions, + adapter: AskAdapter, + maxAttempts: number, +): Promise { + let lastError = ''; + for (let i = 0; i < maxAttempts; i++) { + const details = lastError + ? `${opts.details ?? docsFor(t) ?? ''}\n (last attempt: ${lastError})`.trim() + : detailsFor(opts.details, t); + const raw = await adapter.text({ title: opts.title, details }); + if (raw === null) return null; + try { + // Heuristic: numeric-leafs accept both numeric strings and JSON. + // For text-leafs the raw string IS the answer. + const parsed = parseLeafInput(t, raw); + return t.parse(parsed); + } catch (e: unknown) { + lastError = e instanceof Error ? e.message : String(e); + } + } + return null; +} + +function parseLeafInput(t: Type, raw: string): unknown { + if (t instanceof NumType) { + const n = Number(raw); + if (!Number.isFinite(n)) throw new Error(`'${raw}' is not a number`); + return n; + } + return raw; +} + +/** Resolve aliases / unwrap extensions to the structural form so the + * walker dispatches on the underlying class. */ +function unwrap(t: Type): Type { + // AliasType.simplify resolves through scope to the target. + let cur: Type = t; + if (cur instanceof AliasType) { + cur = cur.simplify(); + } + // Extension — defer to base for the structural shape. The + // Extension's narrowed options ride along through `parse`, so the + // value the consumer constructs still gets validated against the + // Extension's constraints when the caller (fns.ask) wraps the final + // value in `t.parse(...)`. + if (isExtension(cur)) { + return unwrap(cur.base); + } + return cur; +} + +function isExtension(t: Type): t is Extension { + // Avoid importing Extension just for the check at runtime (it's + // also fine to import — but this keeps the consumer's surface + // narrow). Identify Extensions by their `base` field shape. + return 'base' in (t as object) && t.constructor.name === 'Extension'; +} + +/** Pick a useful `details` string: caller-supplied wins, type docs + * next, blank otherwise. */ +function detailsFor(callerDetails: string | undefined, t: Type): string | undefined { + if (callerDetails && callerDetails.length > 0) return callerDetails; + return docsFor(t); +} + +function docsFor(t: Type): string | undefined { + const d = (t as unknown as { docs?: string }).docs; + return d && d.length > 0 ? d : undefined; +} diff --git a/packages/ginny/src/context.ts b/packages/ginny/src/context.ts index af4f7bbc..11654eec 100644 --- a/packages/ginny/src/context.ts +++ b/packages/ginny/src/context.ts @@ -1,4 +1,4 @@ -import type { Registry, Engine, Type, Value } from '@aeye/gin'; +import type { Registry, Engine, Type, TypeDef, Value, ObjType } from '@aeye/gin'; import type { Store } from './store'; import type { RunState } from './run-state'; @@ -21,6 +21,89 @@ export interface Ctx { * reject the returned promise when it fires. */ ask?: (question: string, signal?: AbortSignal) => Promise; + /** + * How many programmer invocations deep we are. Top-level (REPL) is 0; + * each `designer.create_new_fn` increments by 1 before invoking + * programmer recursively. The `find_or_create_functions` tool gates + * its `applicable` on this so a recursive programmer at the cap + * can't keep delegating function creation back to itself — it has to + * write the function inline. + */ + programmerDepth?: number; + /** + * The user's original top-level request, captured by the entry point + * before launching the depth-0 programmer. Plumbed through every + * recursive designer/programmer pair so a deep programmer can render + * "what is this work ultimately for" alongside its own immediate + * task. Empty for non-interactive entry points that didn't bother to + * set it. + */ + originalRequest?: string; + /** + * Call-chain ancestry for recursive programmers, oldest → newest. + * Each entry is a function the designer was asked to create at one + * level of nesting. Empty at depth 0; appended once per + * `designer.create_new_fn` before spawning the inner programmer. A + * programmer at depth N reads the chain to understand which caller + * needs its function and why — so it can stay scoped to that need. + */ + programmerChain?: ProgrammerChainEntry[]; + /** + * Set by `designer.create_new_fn` before invoking the inner programmer. + * Tells `test()` how to wrap raw scope args into typed `Value`s and + * tells `finish()` what signature to use when persisting the draft — + * so the saved fn matches what the designer designed instead of being + * `(): or` (an inference of the body's static type). + * + * `argsType` is intentionally `ObjType`, not the generic `Type`: a + * gin function's arguments are always an obj whose props ARE the + * parameter list. Typing it concretely lets downstream tools read + * `argsType.fields` and call `argsType.parse(rawArgs)` without + * narrowing checks, and forces `designer.create_new_fn` to validate + * the input up front. + */ + targetFn?: { + name: string; + /** Parsed (and alias-inlined) args type — used by `test()` to wrap + * raw scope args via `argsType.parse(rawArgs)` and by `write()` + * to bind `args` in the validate scope. */ + argsType: ObjType; + /** Parsed (and alias-inlined) returns type — used by validate / + * static analysis. */ + returnsType: Type; + /** + * Optional source forms for round-trip preservation when the + * designer declared `call.types` aliases. `finish()` writes these + * back verbatim so the saved fn keeps its compact shape; without + * them, `argsType.toJSON()` would emit the verbose inlined form. + */ + callTypes?: Record; + sourceArgs?: TypeDef; + sourceReturns?: TypeDef; + }; +} + +/** Hard cap on programmer recursion. With 0-indexed depth, programmers + * at depth < MAX_PROGRAMMER_DEPTH - 1 can delegate to the designer to + * create more programmers; the deepest one cannot. Set to 3 → max 3 + * programmers in the stack. */ +export const MAX_PROGRAMMER_DEPTH = 3; + +/** + * One step in the programmer call-chain — recorded by the designer at + * each `create_new_fn`. The chain lets a deep programmer reason about + * which parent function depends on its output and what the original + * user request was, instead of seeing only its own isolated signature. + */ +export interface ProgrammerChainEntry { + /** Function name (matches what `finish({ saveAs })` will use). */ + name: string; + /** `argsType.toCode()` — human-readable parameter shape. */ + argsCode: string; + /** `returnsType.toCode()` — human-readable return shape. */ + returnsCode: string; + /** The designer's `description` input — what this function should do. */ + description: string; } export interface Meta {} diff --git a/packages/ginny/src/event-display.ts b/packages/ginny/src/event-display.ts index a710f8dd..67dd750a 100644 --- a/packages/ginny/src/event-display.ts +++ b/packages/ginny/src/event-display.ts @@ -11,7 +11,8 @@ * on the args object — the same reference is reused across the * matching toolStart / toolOutput / toolError events). */ -import { logger } from './logger'; +import { logger, genId } from './logger'; +import { MarkdownStream } from './markdown'; const ESC = '\x1b['; const RESET = `${ESC}0m`; @@ -22,12 +23,21 @@ const DIM = `${ESC}2m`; const PREVIEW_MAX = 120; +/** Sample every Nth streaming partial event for a memory snapshot. + * 100 ≈ once per ~10 KB of streamed content for typical providers — + * fine-grained enough to catch a runaway allocation, coarse enough + * not to flood ginny.log on a normal multi-thousand-token response. */ +const MEM_CHUNK_INTERVAL = 100; + function preview(value: unknown): string { let s: string; - try { - s = typeof value === 'string' ? value : JSON.stringify(value); - } catch { - s = String(value); + if (value instanceof Error) { + // `JSON.stringify(new Error())` is `{}` — surface .message instead. + s = value.message || String(value); + } else if (typeof value === 'string') { + s = value; + } else { + try { s = JSON.stringify(value); } catch { s = String(value); } } if (s == null) return ''; s = s.replace(/\s+/g, ' '); @@ -39,20 +49,69 @@ type LastEventKind = 'thinking' | 'text' | 'tool' | null; export class EventDisplay { private toolStarts = new WeakMap(); private last: LastEventKind = null; + /** + * Has the streamed text line been left open (no trailing newline)? + * Set on every `textPartial`, cleared once we write the terminating + * `\n`. Tracked separately from `last` so that consuming the + * "terminate the line" event (`text` / `textComplete` / a tool + * boundary) can reset this independently — otherwise the next event + * would try to write a second newline. + */ + private textLineOpen = false; + /** Latches when we ever write streamed user text — used by `producedText`. */ + private hasProducedText = false; private color: boolean; private thinkingShownThisTurn = false; + /** Chunk counters for streaming-window memory logs. Reset on each + * `request` event (start of a new turn); incremented on every + * `textPartial` / `reasonPartial`. Every `MEM_CHUNK_INTERVAL` + * partials we drop a `[mem] stream @ N chunks` line so a runaway + * allocation during streaming is localizable inside the + * beforeRequest → afterRequest window. */ + private partialChunks = 0; + private reasonChunks = 0; + private streamLoggedAt = 0; + /** Track total streamed bytes per turn for the end-of-stream report. */ + private streamBytesText = 0; + private streamBytesReason = 0; + /** + * Streaming markdown renderer. All streamed prose chunks pump through + * here so headings, code fences, lists, bold/italic, links etc. + * actually render rather than appearing as raw `**text**` / + * ```` ```ts ```` markup. Stateful — fenced-code mode carries + * across chunks within one segment. + */ + private markdown: MarkdownStream; constructor(useColor = !!process.stderr.isTTY) { this.color = useColor; + this.markdown = new MarkdownStream(process.stdout, useColor); } private c(code: string, text: string): string { return this.color ? `${code}${text}${RESET}` : text; } + /** Log a memory snapshot every `MEM_CHUNK_INTERVAL` partial chunks + * during streaming. Keeps the AI lib's chunk accumulation visible + * inside the beforeRequest → afterRequest window without flooding + * the log (one line per ~100 chunks instead of per chunk). */ + private maybeLogStreamMem(): void { + const total = this.partialChunks + this.reasonChunks; + if (total - this.streamLoggedAt < MEM_CHUNK_INTERVAL) return; + this.streamLoggedAt = total; + logger.mem(`stream @ ${total} chunks (text=${this.partialChunks} reason=${this.reasonChunks})`); + } + private breakIfText(): void { - if (this.last === 'text') { + if (this.textLineOpen) { + // Drain any partial-line markdown buffer through the renderer + // before emitting the terminating newline. Without this, a + // mid-line tool boundary would print the partial line raw and + // discard the buffer. + this.markdown.flush(); process.stdout.write('\n'); + this.textLineOpen = false; } } @@ -63,11 +122,28 @@ export class EventDisplay { // Reset the per-turn thinking cue so the next iteration can // re-emit it; no visible separator between turns. this.thinkingShownThisTurn = false; - if (event.iterations > 0) logger.log(`── turn ${(event.iterations ?? 0) + 1} ──`); + // Reset per-turn streaming counters so the next stream's + // `[mem] stream @ N chunks` lines start at 0. + this.partialChunks = 0; + this.reasonChunks = 0; + this.streamLoggedAt = 0; + this.streamBytesText = 0; + this.streamBytesReason = 0; + if (event.iterations > 0) { + logger.log(`── turn ${(event.iterations ?? 0) + 1} ──`); + // Per-turn memory marker — pair with beforeRequest / + // afterRequest snapshots from ai.ts to see how much memory + // each turn actually retains after GC. + logger.mem(`turn ${(event.iterations ?? 0) + 1}`); + } break; } case 'reasonPartial': { + this.reasonChunks++; + const reasoningChunk = event.content ?? ''; + if (typeof reasoningChunk === 'string') this.streamBytesReason += reasoningChunk.length; + this.maybeLogStreamMem(); if (!this.thinkingShownThisTurn) { this.breakIfText(); process.stderr.write(`${this.c(DIM, '(thinking…)')}\n`); @@ -80,18 +156,57 @@ export class EventDisplay { case 'reason': { const text = event.reasoning?.content ?? ''; if (text) logger.log(`reasoning: ${preview(text)}`); + // Reasoning stream finished — capture how much we accumulated + // and where memory landed. + logger.mem(`reason end (chunks=${this.reasonChunks} bytes=${this.streamBytesReason})`); break; } case 'textPartial': { - process.stdout.write(event.content ?? ''); - this.last = 'text'; + this.partialChunks++; + const chunk = event.content ?? ''; + if (chunk) { + if (typeof chunk === 'string') this.streamBytesText += chunk.length; + // Pump every streamed chunk through the markdown renderer. + // It buffers partial lines internally, so headings / fences + // / inline formatting render correctly across chunk + // boundaries. + this.markdown.write(chunk); + this.last = 'text'; + this.textLineOpen = true; + this.hasProducedText = true; + } + this.maybeLogStreamMem(); + break; + } + + case 'text': + case 'textComplete': { + // Streaming for this text segment is done — `text` fires when + // the model finishes its prose for a turn (just before any + // tool calls), `textComplete` fires once at the very end of + // the response. Drain the markdown buffer (partial trailing + // line, dangling code fence reset, etc.) and terminate with + // a newline so the next output starts on its own row. + this.markdown.flush(); + if (this.textLineOpen) { + process.stdout.write('\n'); + this.textLineOpen = false; + } + // End-of-stream memory snapshot — the `[mem] stream end` line + // brackets the streaming window so the delta vs. + // `beforeRequest` shows whether streaming itself bloated the + // heap (chunk accumulation inside the AI lib + ginny's + // markdown buffer). + logger.mem(`stream end (textChunks=${this.partialChunks} textBytes=${this.streamBytesText})`); break; } case 'refusal': { this.breakIfText(); - process.stderr.write(`${this.c(RED, `refusal: ${event.content ?? ''}`)}\n`); + const line = `refusal: ${preview(event.content ?? '')}`; + process.stderr.write(`${this.c(RED, line)}\n`); + logger.log(`refusal: ${event.content ?? ''}`); this.last = 'text'; break; } @@ -102,6 +217,10 @@ export class EventDisplay { const line = `→ ${event.tool.name}(${preview(event.args)})`; process.stderr.write(`${this.c(CYAN, line)}\n`); logger.log(line); + // Memory snapshot bracketing each tool call. Pair with the + // matching toolOutput snapshot below to see per-tool deltas + // when post-morteming an OOM via grep ginny.log. + logger.mem(`tool=${event.tool.name} start`); this.last = 'tool'; break; } @@ -112,6 +231,7 @@ export class EventDisplay { const line = `← ${event.tool.name} (${elapsed}ms): ${preview(event.result)}`; process.stderr.write(`${this.c(GREEN, line)}\n`); logger.log(line); + logger.mem(`tool=${event.tool.name} done`); this.last = 'tool'; break; } @@ -119,9 +239,17 @@ export class EventDisplay { case 'toolError': { const started = this.toolStarts.get(event.args); const elapsed = started ? Date.now() - started : 0; - const line = `✗ ${event.tool.name} (${elapsed}ms): ${event.error}`; + // Cap the on-screen error: zod / aggregate errors can run hundreds + // of lines and bury the live view. Full text still goes to ginny.log. + // The 6-char id ties the one-liner to the full stack/args + // dumped to ginny.log — grep `` to surface everything. + const id = genId(); + const line = `✗ ${event.tool.name} [${id}] (${elapsed}ms): ${preview(event.error)}`; process.stderr.write(`${this.c(RED, line)}\n`); - logger.log(line); + logger.log(`[${id}] tool=${event.tool.name} (${elapsed}ms) error: ${event.error}`); + const stack = (event.error as { stack?: string } | undefined)?.stack; + if (stack) logger.log(`[${id}] stack:\n${stack}`); + try { logger.log(`[${id}] args: ${JSON.stringify(event.args)}`); } catch { /* ignore */ } this.last = 'tool'; break; } @@ -135,16 +263,18 @@ export class EventDisplay { } case 'textReset': { - const line = `(reset: ${event.reason ?? 'unspecified'})`; + // The reason can be a multi-line forget/output retry message + // (e.g. zod validation). Show a one-liner; full text → ginny.log. + const line = `(reset: ${preview(event.reason ?? 'unspecified')})`; process.stderr.write(`${this.c(DIM, line)}\n`); - logger.log(line); + logger.log(`(reset: ${event.reason ?? 'unspecified'})`); break; } } } - /** Did we ever stream user-visible text? */ + /** Did we ever stream user-visible text during the run? */ get producedText(): boolean { - return this.last === 'text'; + return this.hasProducedText; } } diff --git a/packages/ginny/src/fns-global.ts b/packages/ginny/src/fns-global.ts deleted file mode 100644 index d43c177e..00000000 --- a/packages/ginny/src/fns-global.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ExprDef, Type, Value } from '@aeye/gin'; -import type { Ctx } from './context'; - -/** - * Wire a saved gin function (`fns/.json`) into the engine as a - * runtime callable global. With this in place a program can call - * `({...args})` directly — same calling convention as the - * built-in globals (`fns.fetch`, `fns.llm`). - * - * The body is evaluated lazily on each invocation: each call constructs - * a fresh root scope with the caller's args bound, so saved fns can be - * recursive, can reference other globals (`vars.*`, other saved fns), - * and stay decoupled from the parent program's scope. - * - * Parts of `vars-global.ts` already use the same `engine.registerGlobal` - * API — fns and vars share the global namespace, so a fn name can't - * collide with a var name. - */ -export function registerFnAsGlobal( - ctx: Ctx, - name: string, - type: Type, - body: ExprDef, -): void { - const callable = async (argsValue: Value): Promise => { - const args = (argsValue?.raw ?? {}) as Record; - return await ctx.engine.run(body, args); - }; - ctx.engine.registerGlobal(name, { type, value: callable }); -} diff --git a/packages/ginny/src/index.ts b/packages/ginny/src/index.ts index 004a0664..4c6a415b 100644 --- a/packages/ginny/src/index.ts +++ b/packages/ginny/src/index.ts @@ -1,8 +1,27 @@ #!/usr/bin/env node +// NOTE: AbortSignal listener cap. Lifted in two places, by design: +// +// 1. `esbuild.config.cjs` banner — patches `globalThis.AbortController` +// so every signal created in the bundle (ours, the AI library's, +// SDK internals, fetch's) is uncapped from birth. The banner runs +// before any imported module's top-level code, so SDKs that capture +// `globalThis.AbortController` at module init see the patched ctor. +// +// 2. `setMaxListeners(Infinity)` below — sets the global default for +// future EventTargets/EventEmitters. Belt-and-suspenders: in some +// Node versions EventTarget captures `defaultMaxListeners` at module +// load and doesn't re-read it, which is why (1) is the load-bearing +// fix; this one is the cheap "in case it works" addition. import * as readline from 'readline'; +import events from 'events'; +events.setMaxListeners(Number.POSITIVE_INFINITY); + import type { Message } from '@aeye/core'; import { programmer } from './prompts/programmer'; import { EventDisplay } from './event-display'; +import { logger, genId } from './logger'; +import { aiInfo } from './ai'; +import { setRuntimeSignal } from './runtime-signal'; /** * Single readline interface used for both the REPL prompt loop AND for @@ -18,6 +37,10 @@ const rl = readline.createInterface({ terminal: true, }); +// Emit `keypress` events on stdin so we can listen for ESC during an +// in-flight request and use it to interrupt the run cleanly. +readline.emitKeypressEvents(process.stdin); + /** * Resolve with the user's typed answer. Wired into every prompt's ctx * as `ask`, surfacing the `ask` tool to the model. Writes the question @@ -86,7 +109,7 @@ function startSpinner(label: string): () => void { const history: Message[] = []; async function runRequest(request: string): Promise { - const stopSpinner = startSpinner('ginny is thinking…'); + const stopSpinner = startSpinner('ginny is thinking… (ESC to interrupt)'); let spinnerStopped = false; const ensureSpinnerStopped = () => { if (!spinnerStopped) { @@ -95,11 +118,53 @@ async function runRequest(request: string): Promise { } }; - // Wire Ctrl+C during a request so we can abort the stream cleanly - // without tearing down the whole REPL. + // Two ways to abort an in-flight request without killing the REPL: + // ESC — primary interrupt; brings the user back to `> ` + // Ctrl+C — also aborts, kept for muscle memory + // Once the request finishes, both listeners are removed so a Ctrl+C + // at the idle prompt still exits the process via Node's default. + // + // ESC delivery is fiddly across platforms: readline reports it as + // `key.name === 'escape'`; some terminals deliver only the raw + // sequence in `str`; on Windows `data` events can fire ahead of + // keypress decoding. Listen on both channels and match name OR raw + // ESC byte so we don't miss the press. const abort = new AbortController(); - const onSigint = () => abort.abort(); + const triggerInterrupt = (source: string) => { + if (abort.signal.aborted) return; + process.stderr.write(`\n(interrupting via ${source}…)\n`); + abort.abort(); + }; + const onSigint = () => triggerInterrupt('Ctrl+C'); + const onKeypress = ( + str: string | undefined, + key: { name?: string; sequence?: string } | undefined, + ) => { + const isEsc = key?.name === 'escape' + || str === '\x1b' || key?.sequence === '\x1b'; + if (isEsc) triggerInterrupt('ESC'); + }; + const onData = (chunk: Buffer | string) => { + const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + // A bare ESC arrives as a single 0x1b byte; an ESC-prefixed + // sequence (arrow keys, etc.) is two-or-more bytes starting with + // 0x1b. Only treat the lone byte as an interrupt. + if (buf.length === 1 && buf[0] === 0x1b) triggerInterrupt('ESC'); + }; process.on('SIGINT', onSigint); + process.stdin.on('keypress', onKeypress); + process.stdin.on('data', onData); + // Make sure stdin is actually flowing while we wait — readline pauses + // it between `rl.question` calls on some platforms, which would mute + // both keypress and data events. + if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + // Publish the signal for natives (fns.fetch / fns.llm) to forward + // into their underlying I/O — the gin engine doesn't thread ctx + // through native calls, so they can't read it from `ctx.signal`. + setRuntimeSignal(abort.signal); const display = new EventDisplay(); @@ -109,9 +174,24 @@ async function runRequest(request: string): Promise { const events = programmer.get( 'stream', {}, - { signal: abort.signal, messages: history, ask: askUser }, + { + signal: abort.signal, + messages: history, + ask: askUser, + // Top-level request — propagates down through every recursive + // designer/programmer pair so deep programmers know what the + // user originally asked for, not just their immediate task. + originalRequest: request, + }, ); for await (const event of events) { + // After ESC (or Ctrl+C), stop draining further events. The inner + // streamer's signal listener tears down its in-flight request, + // but the model may still be queuing up follow-up tool calls + // that we shouldn't process — bail here so the run actually + // unwinds back to the prompt instead of grinding through one + // last iteration. + if (abort.signal.aborted) break; // Keep the "ginny is thinking…" spinner alive until the model // actually produces something. `request`/`requestUsage` fire at // the start of an iteration, before any output, so they don't @@ -126,21 +206,73 @@ async function runRequest(request: string): Promise { } if (!display.producedText) { // No text response (e.g. pure tool-only run, or empty answer). - process.stdout.write('(no output)'); + process.stdout.write('(no output)\n'); } - process.stdout.write('\n'); + // No unconditional trailing newline here — when text WAS streamed, + // `EventDisplay` already terminated it on the `text` / + // `textComplete` event. Adding another `\n` here would produce a + // blank line before the next `> ` prompt. } catch (e: unknown) { ensureSpinnerStopped(); const err = e as { message?: string; stack?: string; name?: string }; if (abort.signal.aborted) { process.stderr.write('\n(cancelled)\n'); } else { - console.error('\nError:', err.message ?? String(e)); - if (err.stack) console.error(err.stack); + // Keep the on-screen error short — zod / aggregate errors can + // dump hundreds of lines that bury the prompt. Full message and + // stack go to ginny.log for post-mortem; the 6-char id makes + // both ends of the trail joinable via `grep ginny.log`. + const id = genId(); + const raw = err.message ?? String(e); + const oneLiner = raw.replace(/\s+/g, ' ').trim(); + const short = oneLiner.length > 200 ? `${oneLiner.slice(0, 200)}…` : oneLiner; + console.error(`\nError [${id}]: ${short}`); + console.error(`(see ginny.log — search for ${id})`); + logger.log(`[${id}] runRequest error: ${raw}`); + if (err.stack) logger.log(`[${id}] stack:\n${err.stack}`); } } finally { process.off('SIGINT', onSigint); + process.stdin.off('keypress', onKeypress); + process.stdin.off('data', onData); + setRuntimeSignal(undefined); + } +} + +/** + * Render the post-clear startup summary: which providers came up, which + * were skipped (with reasons), the unique set of model IDs the user has + * pinned via env, and whether web research is wired up. When Tavily is + * unset, point the user at the env var so the fix is one step away. + */ +function printStartupBanner(): void { + const lines: string[] = []; + lines.push('ginny ready.'); + lines.push(''); + + const providers = aiInfo.providers.length > 0 + ? aiInfo.providers.join(', ') + : '(none)'; + lines.push(`Providers: ${providers}`); + for (const reason of aiInfo.skipped) { + lines.push(` · skipped ${reason}`); } + + if (aiInfo.models.size > 0) { + lines.push(`Models: ${[...aiInfo.models].join(', ')}`); + } else { + lines.push('Models: (defaults — no GIN_MODEL or GIN__MODEL set)'); + } + + if (aiInfo.webSearch) { + lines.push('Web research: enabled (tavily)'); + } else { + lines.push('Web research: disabled — set TAVILY_API_KEY in config.json or env to enable (tavily.com)'); + } + + lines.push(''); + lines.push('Type a request. ESC interrupts a run, Ctrl+C exits.'); + console.log(lines.join('\n') + '\n'); } async function main() { @@ -154,7 +286,7 @@ async function main() { return; } - console.log('ginny ready. Type a request (Ctrl+C to exit).\n'); + printStartupBanner(); const prompt = () => { rl.question('> ', async (line) => { @@ -170,7 +302,13 @@ async function main() { prompt(); } -main().catch((e) => { - console.error(e); +main().catch((e: unknown) => { + const err = e as { message?: string; stack?: string }; + const raw = err.message ?? String(e); + const oneLiner = raw.replace(/\s+/g, ' ').trim(); + const short = oneLiner.length > 200 ? `${oneLiner.slice(0, 200)}…` : oneLiner; + console.error(`Error: ${short}`); + logger.log(`Error: ${raw}`); + if (err.stack) logger.log(err.stack); process.exit(1); }); diff --git a/packages/ginny/src/logger.ts b/packages/ginny/src/logger.ts index 5fec0be6..b0980abd 100644 --- a/packages/ginny/src/logger.ts +++ b/packages/ginny/src/logger.ts @@ -1,17 +1,37 @@ import fs from 'fs'; import path from 'path'; +import crypto from 'crypto'; +import v8 from 'v8'; /** - * Append-only logger that writes to `./ginny.log` in the session CWD. + * Generate a short (6 hex chars) "errorable-work" id. Stamp it on a + * pair of log lines — `[id] start` before the work, then either + * `[id] ok` or `[id] error: ` after — and surface the + * id in the user-visible one-liner so a `grep ginny.log` pulls + * up the full context (params, stack, retry attempts, etc.). + */ +export function genId(): string { + return crypto.randomBytes(3).toString('hex'); +} + +/** + * Per-startup logger that writes to `./ginny.log` in the session CWD. * Wired into AI hooks + sub-agent invocations so every LLM request and * response is captured for later inspection / debugging. + * + * Truncated at startup — each ginny invocation gets a fresh log so the + * file reflects only the current session. Older sessions roll off + * naturally; if you need history, copy the file before launching ginny + * again. */ export class Logger { private stream: fs.WriteStream; constructor(cwd: string) { const filePath = path.join(cwd, 'ginny.log'); - this.stream = fs.createWriteStream(filePath, { flags: 'a' }); + // 'w' truncates on open (vs. 'a' which appends). One file per + // session keeps the post-mortem signal-to-noise ratio high. + this.stream = fs.createWriteStream(filePath, { flags: 'w' }); this.log(`=== ginny session start: ${new Date().toISOString()} ===`); } @@ -20,7 +40,86 @@ export class Logger { this.stream.write(`[${ts}] ${message}\n`); } - /** Serialize an object for the log. Circular refs / functions → placeholders. */ + // ─── memory instrumentation ────────────────────────────────────────────── + + /** Highest `rss` we've ever observed this session, in bytes. Used to + * detect new high-water marks and tag them WARN in the log. */ + private peakRss = 0; + /** Heap limit in bytes (v8's heap_size_limit) — the ceiling V8 will + * OOM at. Cached at first read. */ + private heapLimitBytes: number | undefined; + + /** Snapshot the current process memory and emit a compact one-liner + * to `ginny.log`. Format: + * + * [mem]