diff --git a/.changeset/app-authoring-lint.md b/.changeset/app-authoring-lint.md new file mode 100644 index 0000000000..ff792a52f9 --- /dev/null +++ b/.changeset/app-authoring-lint.md @@ -0,0 +1,5 @@ +--- +"apps": minor +--- + +Export offline source checks and an Oxlint plugin for app-authoring conventions. diff --git a/apps/docs/content/author-an-app.mdx b/apps/docs/content/author-an-app.mdx index d9d56ff7e1..d14783f579 100644 --- a/apps/docs/content/author-an-app.mdx +++ b/apps/docs/content/author-an-app.mdx @@ -26,6 +26,51 @@ app. An unavailable or unsupported package fails the build and leaves the active app unchanged. The host protocol is small and shared; early beta releases do not promise indefinite support for every past version. +## Check source locally + +Before creating or deploying an app, run `executor apps check --files ./hello`. +It reads the directory locally and checks the manifest, source paths, UTF-8 text, +and source/public-file limits. It does not contact a host or change the files. +It requires an exact `dependencies.apps` pin and keeps the version you tested; +it does not compare that pin with the current host release. A host can still +refuse a release it does not support. + +Use Oxlint for app-authoring conventions. Generate its config from the `apps` +package: + +```js +import { writeFile } from "node:fs/promises"; +import { appConfig } from "apps/oxlint"; + +await writeFile(".oxlintrc.json", JSON.stringify(appConfig(), null, 2)); +``` + +Then run the Oxlint CLI with that config. The rules reject host-only imports, +global HTTP in app code, legacy account access, missing mutation approval, and a +root entry that does not default-export `defineApp`. Browser code under `ui/` +may use browser HTTP, but CommonJS loading through global `require`, global +`module`, or `node:module`/`module` imports is rejected throughout the app. + +Framework calls must keep their identity. Import `defineApp`, `mutation`, +`query` and `decodeJson` by name from exactly `apps`, in the module that calls +them. A renamed import such as `import { mutation as write } from "apps"` is +allowed when the imported function is called directly. The app entry must use +`export default defineApp(...)` directly; parentheses and TypeScript expression +wrappers are allowed. +Import `QueryContext` and `MutationContext` types directly from `apps` in the +module that uses them. Do not re-export or alias these types through a helper. +Use ESM imports in helpers and browser code too. + +Do not assign these functions to another value, destructure them, pass or return +them, or re-export them. Default, namespace and dynamic imports of `apps` are +rejected. Every app source file is checked, including helpers, so a local barrel +cannot hide a framework function. Ordinary helper modules can use named, +default, namespace or literal dynamic imports. DTO fields named `mutation` or +`write` remain valid. + +These checks inspect source structure. They do not type-check the app, evaluate +application code, or prove that a host can build and run it. + `defineApp` declares behavior and does not take a name. Set the package name in `package.json`, such as `"name": "@team.executor.community/calendar"` when publishing under the organization `team`. The name diff --git a/bun.lock b/bun.lock index 53c5710daa..b2a66db7dc 100644 --- a/bun.lock +++ b/bun.lock @@ -888,8 +888,6 @@ "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.0", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-tgprkax8mcF+BiHJQBdoQ+REqtvvnLlYzV0yCeLMdcv3pJIutdlinWNHUzN1jMOT5VRlYTtMmDdaoPrtd3cBAQ=="], - "@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], - "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.4.1", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-x64": "0.4.1", "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-arm64-musl": "0.4.1", "@astrojs/compiler-binding-linux-x64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-x64-musl": "0.4.1", "@astrojs/compiler-binding-wasm32-wasi": "0.4.1", "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.1", "@astrojs/compiler-binding-win32-x64-msvc": "0.4.1" } }, "sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA=="], "@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.4.1", "", { "os": "android", "cpu": "arm64" }, "sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog=="], @@ -1282,10 +1280,6 @@ "@electric-sql/pglite": ["@electric-sql/pglite@0.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], - "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], - - "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], - "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -5080,10 +5074,6 @@ "@astrojs/astro2tsx/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], - "@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "@astrojs/check/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], - "@astrojs/internal-helpers/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "@astrojs/internal-helpers/shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], @@ -5216,6 +5206,10 @@ "@prisma/dev/@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + "@prisma/dev/@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], + + "@prisma/dev/@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], + "@prisma/dev/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], @@ -5398,6 +5392,8 @@ "better-call/rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + "blume/@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], + "blume/es-module-lexer": ["es-module-lexer@3.0.2", "", {}, "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg=="], "blume/graphql": ["graphql@17.0.2", "", {}, "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ=="], @@ -5406,6 +5402,8 @@ "blume/shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], + "blume/twoslash": ["twoslash@0.3.9", "", { "dependencies": { "@typescript/vfs": "^1.6.4", "twoslash-protocol": "0.3.9" }, "peerDependencies": { "typescript": "^5.5.0 || ^6.0.0" } }, "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q=="], + "blume/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "blume/undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], @@ -5598,6 +5596,10 @@ "vite-plus/@oxlint/plugins": ["@oxlint/plugins@1.79.0", "", {}, "sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg=="], + "vite-plus/@vitest/browser": ["@vitest/browser@4.1.11", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w=="], + + "vite-plus/@vitest/browser-preview": ["@vitest/browser-preview@4.1.11", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.11" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-iPKSE6Ibayey6HFgK1V1/aHgyhx7HSRk1YMi+lnBZGmlIiNV5Uc7xRkD9Su8RDylTxDECK23t7kTHdRKoqSYDQ=="], + "vite-plus/@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], "vite-plus/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], @@ -5634,10 +5636,6 @@ "@astrojs/astro2tsx/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "@astrojs/check/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "@astrojs/internal-helpers/shiki/@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], "@astrojs/internal-helpers/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], @@ -5806,6 +5804,10 @@ "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "blume/@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "blume/@astrojs/check/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + "blume/shiki/@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], "blume/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], @@ -5966,6 +5968,8 @@ "unzipper/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "vite-plus/@vitest/browser/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "vite-plus/@vitest/mocker/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "vite-plus/vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -5976,12 +5980,6 @@ "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - "@astrojs/check/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "@astrojs/check/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "@astrojs/react/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], "@astrojs/react/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], @@ -6120,6 +6118,10 @@ "astro/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], + "blume/@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "blume/@astrojs/check/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -6168,12 +6170,6 @@ "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], - "@astrojs/check/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - - "@astrojs/check/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@puppeteer/browsers/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -6182,6 +6178,12 @@ "@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "blume/@astrojs/check/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "blume/@astrojs/check/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "blume/@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -6189,5 +6191,11 @@ "node-html-parser/css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "blume/@astrojs/check/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "blume/@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "blume/@astrojs/check/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], } } diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 709e72b9f5..d1690cb1f6 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -8449,7 +8449,7 @@ export const scenarios = plan({ localAppsCliFailures: { file: "local-apps-cli.spec.ts", title: - "apps CLI reports the host's error code, status and fields as text and JSON, says when the CLI is out of date, refuses source over the limits or not UTF-8 before uploading, and reads EXECUTOR_HOST", + "apps CLI reports host errors, refuses source over the limits or not UTF-8 before uploading, checks source offline, and reads EXECUTOR_HOST", targets: { local: scheduled, "self-host": na("The CLI's hosted path needs a browser OAuth login; local uses an API key."), @@ -8469,7 +8469,7 @@ export const scenarios = plan({ localAppsCliRelease: { file: "local-apps-cli.spec.ts", title: - "apps CLI prints the host's apps release, requires --files, and deploys the documented two-file app; the local Executor app pins the same release", + "apps CLI prints the host's apps release, checks app metadata offline, lints app conventions and deploys the documented two-file app", targets: { local: scheduled, "self-host": na("The CLI's hosted path needs a browser OAuth login; local uses an API key."), diff --git a/e2e/tests/local-apps-cli.spec.ts b/e2e/tests/local-apps-cli.spec.ts index 599027b661..9d172ca961 100644 --- a/e2e/tests/local-apps-cli.spec.ts +++ b/e2e/tests/local-apps-cli.spec.ts @@ -130,7 +130,71 @@ const cli = Effect.gen(function* () { }), ), ); - return { run, origin, fs, entry, environment }; + const runTool = (name: string, command: string, args: readonly string[], cwd: string) => + evidence.step( + name, + Effect.scoped( + Effect.gen(function* () { + const child = yield* processes.spawn( + ChildProcess.make(command, args, { + cwd, + extendEnv: false, + env: { + PATH: process.env.PATH ?? "", + HOME: home, + DO_NOT_TRACK: "1", + }, + stdout: "pipe", + stderr: "pipe", + forceKillAfter: "3 seconds", + }), + ); + const [code, stdout, stderr] = yield* Effect.all( + [ + child.exitCode, + child.stdout.pipe(Stream.decodeText(), Stream.mkString), + child.stderr.pipe(Stream.decodeText(), Stream.mkString), + ], + { concurrency: 3 }, + ).pipe(Effect.timeout("60 seconds")); + return { code: Number(code), stdout, stderr }; + }), + ), + ); + const writeOxlintConfig = (cwd: string) => + runTool( + "Load appConfig from the built apps package", + "node", + [ + "--input-type=module", + "--eval", + 'import { writeFile } from "node:fs/promises"; import { appConfig } from "apps/oxlint"; await writeFile(".oxlintrc.json", JSON.stringify(appConfig()));', + ], + cwd, + ); + const oxlint = (name: string, cwd: string) => + runTool( + name, + path.resolve(process.cwd(), "node_modules/.bin/oxlint"), + [ + "--threads=2", + "--config", + ".oxlintrc.json", + "--no-ignore", + "--format", + "json", + "index.ts", + "src/index.ts", + "ui/index.ts", + "src/commonjs.cjs", + "src/shadowed-module.cjs", + "ui/commonjs.cjs", + "helpers.ts", + "operations.ts", + ], + cwd, + ); + return { run, oxlint, writeOxlintConfig, origin, fs, path, entry, environment }; }); layer(TestLive, { excludeTestServices: true })("Local apps CLI", (it) => { @@ -376,7 +440,7 @@ layer(TestLive, { excludeTestServices: true })("Local apps CLI", (it) => { const target = yield* Target, evidence = yield* Evidence, api = yield* Api; - const { run, origin, fs } = yield* cli; + const { run, oxlint, writeOxlintConfig, origin, fs, path } = yield* cli; const session = yield* api.session(); // The host reports the exact apps release; the CLI holds no version of its own. const framework = yield* run(["framework", "--host", origin], true); @@ -398,6 +462,268 @@ layer(TestLive, { excludeTestServices: true })("Local apps CLI", (it) => { const hello = yield* fs.makeTempDirectoryScoped({ prefix: "executor-apps-hello-" }); yield* fs.writeFileString(`${hello}/index.ts`, helloIndex); yield* fs.writeFileString(`${hello}/package.json`, `${helloPackage(version)}\n`); + const checked = yield* run(["check", "--files", "."], false, hello); + expect(checked.code, checked.stderr).toBe(0); + expect(JSON.parse(checked.stdout)).toMatchObject({ ok: true, files: 2, issues: [] }); + + // An exact pin stays as tested. Offline check does not compare it with this host or rewrite it. + const testedPin = "0.0.1-beta.90"; + yield* fs.writeFileString(`${hello}/package.json`, `${helloPackage(testedPin)}\n`); + const preserved = yield* run(["check", "--files", "."], false, hello); + expect(preserved.code, preserved.stderr).toBe(0); + expect(yield* fs.readFileString(`${hello}/package.json`)).toContain(testedPin); + + const rangePin = `^${testedPin}`; + yield* fs.writeFileString(`${hello}/package.json`, `${helloPackage(rangePin)}\n`); + const range = yield* run(["check", "--files", "."], false, hello); + expect(range.code).toBe(1); + expect(range.stderr).toBe(""); + expect(JSON.parse(range.stdout)).toMatchObject({ + ok: false, + issues: [ + { code: "framework-pin", message: "dependencies.apps must be an exact version" }, + ], + }); + expect(yield* fs.readFileString(`${hello}/package.json`)).toContain(rangePin); + + yield* fs.writeFileString(`${hello}/package.json`, `${helloPackage(version)}\n`); + + // The shared config loads the published package by specifier, checks only the root entry, + // and permits browser HTTP under ui/. + const localRoot = yield* fs.makeTempDirectoryScoped({ + directory: process.cwd() + "/.local", + prefix: "executor-apps-oxlint-", + }); + yield* fs.makeDirectory(`${localRoot}/node_modules`); + yield* fs.symlink( + path.resolve(process.cwd(), "packages/apps/dist"), + `${localRoot}/node_modules/apps`, + ); + yield* fs.writeFileString(`${localRoot}/index.ts`, helloIndex); + yield* fs.makeDirectory(`${localRoot}/src`); + yield* fs.writeFileString( + `${localRoot}/src/index.ts`, + `import { object, query } from "apps"; +import defaultHelper from "../helpers.ts"; +import * as helpers from "../helpers.ts"; +const { mutation: dtoMutation, write: dtoWrite } = await import("../helpers.ts"); +const { write: unrelated } = helpers; +defaultHelper(); dtoMutation({}); dtoWrite({}); unrelated({}); +export const helper = (fetch: (url: string) => unknown) => fetch("https://example.test"); +export const request = (ctx: { fetch: (url: string) => unknown }) => { const { fetch: send } = ctx; return send("https://example.test"); }; +export const readDto = query({ input: object({}) }, (_ctx, dto: { accounts: string[] }) => dto.accounts); +`, + ); + yield* fs.makeDirectory(`${localRoot}/ui`); + yield* fs.writeFileString( + `${localRoot}/ui/index.ts`, + 'void fetch("https://example.test");\n', + ); + yield* fs.writeFileString( + `${localRoot}/src/commonjs.cjs`, + 'const helper = "server";\nvoid helper;\n', + ); + yield* fs.writeFileString( + `${localRoot}/src/shadowed-module.cjs`, + 'function ordinaryHelper(module) { return module.require("ordinary-helper"); }\nvoid ordinaryHelper;\n', + ); + yield* fs.writeFileString( + `${localRoot}/ui/commonjs.cjs`, + 'const helper = "browser";\nvoid helper;\n', + ); + yield* fs.writeFileString(`${localRoot}/package.json`, `${helloPackage(version)}\n`); + yield* fs.writeFileString( + `${localRoot}/helpers.ts`, + `const dto = { mutation: () => null, write: () => null }; +export const { mutation, write } = dto; +let ordinary; +ordinary = write; +export default ordinary; +`, + ); + yield* fs.writeFileString( + `${localRoot}/operations.ts`, + `import { mutation as write, decodeJson } from "apps"; +export const approved = write({ approval: undefined }, async (ctx) => { + const response = await ctx.fetch("https://example.test"); + return decodeJson(response, {}); +}); +`, + ); + const config = yield* writeOxlintConfig(localRoot); + expect(config.code, config.stderr).toBe(0); + const accepted = yield* oxlint("Oxlint accepts app, nested, and browser source", localRoot); + yield* evidence.json("oxlint-valid.json", accepted); + expect(accepted.code, accepted.stderr).toBe(0); + + // CommonJS loader globals are rejected in server and browser helpers. A local `module` + // parameter remains legal, and the ordinary ESM helper controls above stay valid. + yield* fs.writeFileString( + `${localRoot}/src/commonjs.cjs`, + 'const direct = require("apps");\nconst loaded = module.require("apps");\nconst alias = module;\nalias.require("apps");\nvoid [direct, loaded];\n', + ); + yield* fs.writeFileString( + `${localRoot}/ui/commonjs.cjs`, + 'module.require("browser-helper");\n', + ); + const commonJsRejected = yield* oxlint( + "Oxlint rejects CommonJS server and UI helpers", + localRoot, + ); + yield* evidence.json("oxlint-commonjs.json", commonJsRejected); + expect(commonJsRejected.code).toBe(1); + expect(commonJsRejected.stderr).toBe(""); + const commonJsDiagnostics = JSON.parse(commonJsRejected.stdout).diagnostics; + const serverCommonJs = commonJsDiagnostics.filter( + (diagnostic: { filename: string }) => diagnostic.filename === "src/commonjs.cjs", + ); + const uiCommonJs = commonJsDiagnostics.filter( + (diagnostic: { filename: string }) => diagnostic.filename === "ui/commonjs.cjs", + ); + expect(serverCommonJs).toHaveLength(3); + expect(uiCommonJs).toHaveLength(1); + expect( + [...serverCommonJs, ...uiCommonJs].every( + (diagnostic: { code: string }) => diagnostic.code === "eslint(no-restricted-globals)", + ), + ).toBe(true); + expect( + commonJsDiagnostics.some( + (diagnostic: { filename: string }) => diagnostic.filename === "src/shadowed-module.cjs", + ), + ).toBe(false); + yield* fs.writeFileString( + `${localRoot}/src/commonjs.cjs`, + 'const helper = "server";\nvoid helper;\n', + ); + yield* fs.writeFileString( + `${localRoot}/ui/commonjs.cjs`, + 'const helper = "browser";\nvoid helper;\n', + ); + + yield* fs.writeFileString( + `${localRoot}/index.ts`, + `import { defineApp, mutation, object, router, type QueryContext } from "apps"; +import { Effect } from "effect"; +const direct = mutation({ input: object({}) }, async () => null); +let requirements; +requirements = { accounts: {} }; +const legacy = defineApp(requirements, { tools: router({}) }); +const readLegacy = (ctx: QueryContext<{}>) => { let old; old = ctx; return old.accounts; }; +const host = globalThis; +host.fetch("https://example.test"); +export const app = defineApp({}, { tools: router({ direct }) }); +void [Effect, legacy, readLegacy]; +`, + ); + const rejected = yield* oxlint("Oxlint reports app-authoring violations", localRoot); + yield* evidence.json("oxlint-invalid.json", rejected); + expect(rejected.code).toBe(1); + expect(rejected.stdout).toContain("Use `export default defineApp(...)` directly"); + expect(rejected.stdout).toContain("Every `mutation` must explicitly declare"); + const mutationDiagnostics = JSON.parse(rejected.stdout).diagnostics.filter( + (diagnostic: { code: string }) => diagnostic.code === "executor-apps(mutation-approval)", + ); + expect(mutationDiagnostics).toHaveLength(1); + expect(rejected.stdout).toContain( + "Legacy top-level `accounts` requirements are unsupported", + ); + expect(rejected.stdout).toContain( + "Read accounts through `ctx.user.accounts` or `ctx.app.accounts`", + ); + expect(rejected.stdout).toContain("this fetch comes from a global object"); + expect(rejected.stdout).toContain( + "CommonJS loaders, Effect and the Executor SDK are unsupported in app source.", + ); + + const helperBindings = [ + { + name: "default import", + helper: 'export { mutation as default } from "apps";\n', + consumer: 'import write from "./operations.ts";\n', + }, + { + name: "local dynamic import", + helper: 'export { mutation as write } from "apps";\n', + consumer: 'const { write } = await import("./operations.ts");\n', + }, + { + name: "destructured namespace export", + helper: 'import * as api from "apps"; export const { mutation: write } = api;\n', + consumer: 'import { write } from "./operations.ts";\n', + }, + { + name: "late-assigned export", + helper: + 'import { mutation } from "apps"; let write; write = mutation; export { write };\n', + consumer: 'import { write } from "./operations.ts";\n', + }, + { + name: "multi-declarator export", + helper: 'import { mutation } from "apps"; export const marker = 1, write = mutation;\n', + consumer: 'import { write } from "./operations.ts";\n', + }, + { + name: "local late assignment", + helper: 'import { mutation } from "apps"; let write; write = mutation;\n', + consumer: 'import { write } from "./operations.ts";\n', + }, + ]; + for (const { name, helper, consumer } of helperBindings) { + yield* fs.writeFileString(`${localRoot}/operations.ts`, helper); + for (const approval of ["", "approval: undefined, "]) { + yield* fs.writeFileString( + `${localRoot}/index.ts`, + `import { defineApp, object, router } from "apps";\n${consumer}const change = write({ ${approval}input: object({}) }, async () => null);\nexport default defineApp({}, { tools: router({ change }) });\n`, + ); + const helperRejected = yield* oxlint( + `Oxlint rejects ${name} even with ${approval ? "approval" : "no approval"}`, + localRoot, + ); + yield* evidence.json( + `oxlint-${name.replaceAll(" ", "-")}-${approval ? "approved" : "unapproved"}.json`, + helperRejected, + ); + expect(helperRejected.code).toBe(1); + expect(helperRejected.stderr).toBe(""); + const helperDiagnostics = JSON.parse(helperRejected.stdout).diagnostics; + expect(helperDiagnostics).toHaveLength(1); + expect(helperDiagnostics[0].code).toBe("executor-apps(plain-framework-bindings)"); + expect(helperDiagnostics[0].filename).toBe("operations.ts"); + expect(helperDiagnostics[0].message).toContain("Use named imports from `apps`"); + } + } + const contextTypeBarrels = [ + { + name: "context type re-export", + helper: 'export type { QueryContext } from "apps";\n', + consumer: 'import type { QueryContext } from "./operations.ts";\n', + use: "const read = (ctx: QueryContext<{}>) => ctx.accounts; void read;", + }, + { + name: "context type alias export", + helper: + 'import type { QueryContext as Query, MutationContext as Mutation } from "apps"; export type { Query, Mutation };\n', + consumer: 'import type { Query, Mutation } from "./operations.ts";\n', + use: "const read = (ctx: Query<{}>) => ctx.accounts; const write = (ctx: Mutation<{}>) => ctx.accounts; void [read, write];", + }, + ]; + for (const { name, helper, consumer, use } of contextTypeBarrels) { + yield* fs.writeFileString(`${localRoot}/operations.ts`, helper); + yield* fs.writeFileString( + `${localRoot}/index.ts`, + `import { defineApp, router } from "apps";\n${consumer}${use}\nexport default defineApp({}, { tools: router({}) });\n`, + ); + const typeBarrelRejected = yield* oxlint(`Oxlint rejects ${name}`, localRoot); + yield* evidence.json(`oxlint-${name.replaceAll(" ", "-")}.json`, typeBarrelRejected); + expect(typeBarrelRejected.code).toBe(1); + expect(typeBarrelRejected.stderr).toBe(""); + const diagnostics = JSON.parse(typeBarrelRejected.stdout).diagnostics; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe("executor-apps(plain-framework-bindings)"); + expect(diagnostics[0].filename).toBe("operations.ts"); + } + const created = yield* run( ["create", "--host", origin, "--name", "CLI hello", "--files", hello], true, @@ -606,6 +932,14 @@ layer(TestLive, { excludeTestServices: true })("Local apps CLI", (it) => { expect(tooLarge.paths[0]).toBe("large.txt"); expect(tooLarge.stderr).toContain("Executor accepts at most 16 MiB (16,777,216 bytes)"); + const checkedLarge = yield* run(["check", "--files", large], false, large); + yield* evidence.json("check-too-many-bytes.json", checkedLarge); + expect(checkedLarge.code).toBe(1); + expect(checkedLarge.stderr).toBe(""); + expect(JSON.parse(checkedLarge.stdout).issues[0].message).toContain( + "Executor accepts at most 16 MiB (16,777,216 bytes)", + ); + const binary = yield* fs.makeTempDirectoryScoped({ prefix: "executor-apps-binary-" }); yield* fs.makeDirectory(`${binary}/assets`, { recursive: true }); yield* fs.writeFileString(`${binary}/index.ts`, helloIndex); diff --git a/packages/app-management/src/cli.ts b/packages/app-management/src/cli.ts index 8ce8fdcde3..fd4bb63dd8 100644 --- a/packages/app-management/src/cli.ts +++ b/packages/app-management/src/cli.ts @@ -52,6 +52,7 @@ import { requestFiles, type LocalFile, } from "./implementation/cli-source.ts"; +import { checkCommand } from "./implementation/cli-check.ts"; import { UserFacingError } from "@executor-js/utils/user-facing-error"; import { AppClientError, @@ -669,6 +670,7 @@ export const appsCommand = ( }, ]), Command.withSubcommands([ + checkCommand, Command.make("login", { host: Flag.String("host").pipe( Flag.withDescription( diff --git a/packages/app-management/src/implementation/cli-check.ts b/packages/app-management/src/implementation/cli-check.ts new file mode 100644 index 0000000000..b6f08d19aa --- /dev/null +++ b/packages/app-management/src/implementation/cli-check.ts @@ -0,0 +1,48 @@ +import { Console, Effect } from "effect"; +import { Command, Flag } from "effect/cli"; +import { checkApp, type AppCheckFile } from "@executor-js/sdk/core"; +import { readSource } from "./cli-source.ts"; + +const files = Flag.Path("files", { pathType: "either", mustExist: true }).pipe( + Flag.withDescription("App source directory or JSON file to check without contacting a host"), +); + +/** Offline metadata and source checks. readSource enforces the same limits used before uploads. */ +export const checkCommand = Command.make("check", { files }).pipe( + Command.withDescription("Check an app's package manifest and source limits without a host"), + Command.withExamples([ + { + command: "executor apps check --files ./hello", + description: "Check app source before creating or deploying it", + }, + ]), + Command.withHandler(({ files: location }) => + readSource(location).pipe( + Effect.flatMap((source) => { + const appFiles: AppCheckFile[] = source.map((file) => + file.text === undefined + ? { path: file.path, size: file.size } + : { path: file.path, content: file.text, size: file.size }, + ); + const issues = checkApp(appFiles); + return Console.log( + JSON.stringify({ ok: issues.length === 0, files: source.length, issues }), + ).pipe( + Effect.andThen( + Effect.sync(() => { + if (issues.length > 0) process.exitCode = 1; + }), + ), + ); + }), + Effect.catchTag("SourceRejected", (error) => + Console.log( + JSON.stringify({ + ok: false, + issues: [{ code: error.reason, message: error.detail, paths: error.paths }], + }), + ).pipe(Effect.andThen(Effect.sync(() => (process.exitCode = 1)))), + ), + ), + ), +); diff --git a/packages/apps/README.md b/packages/apps/README.md index 779c2247f7..ea6053900a 100644 --- a/packages/apps/README.md +++ b/packages/apps/README.md @@ -27,6 +27,8 @@ export default defineApp({}, async ({ signal }) => ({ | Import | Helper | App dependency | | -------------- | ------------------------ | --------------------------- | +| `apps/check` | `checkApp` | None | +| `apps/oxlint` | `appConfig`, plugin | Oxlint CLI | | `apps/mcp` | `mcpRouter`, `mcpHealth` | `@modelcontextprotocol/sdk` | | `apps/graphql` | `graphqlRouter` | `graphql` | | `apps/openapi` | `liveOpenapiRouter` | None | @@ -35,6 +37,27 @@ MCP and GraphQL are optional peers. Subpath imports isolate their module graphs; optional peers keep unused libraries out of the dependency installation. Apps reach MCP servers over HTTP; they cannot start local processes. +`checkApp(files)` checks the manifest and source-file limits without I/O. The +Executor CLI's `executor apps check --files ` reads a source directory +or JSON file and applies the same checks before upload. It requires `type: module` +and an exact `dependencies.apps` version. It keeps that pin and does not compare +it with a host release. + +`apps/oxlint` exports a plain ESLint-compatible JavaScript plugin and `appConfig()` +for Oxlint. The config loads the installed `apps` package by name. Import +`defineApp`, `mutation`, `query` and `decodeJson` by name from `apps` in the file +that calls each function. Renamed imports such as `mutation as write` are +supported when calls remain direct. The root entry must directly default-export +`defineApp(...)`. Import `QueryContext` and `MutationContext` types directly +from `apps` in the module that uses them. Aliasing, destructuring, passing, +returning or re-exporting a framework function, or re-exporting a context type, +is rejected, including through a helper module. Ordinary helpers and DTO +properties named `mutation` or `write` remain valid. Browser files under `ui/` +may use browser HTTP; app source should use the context's `fetch`. CommonJS +loading through global `require`, global `module`, or `node:module`/`module` +imports is rejected in app and browser source. These checks do not type-check the +app or prove that a host can build and run it. + `defineApp` declares behavior, not a name. Package identity lives in `package.json.name`; the host separately names each installed copy. diff --git a/packages/apps/package.json b/packages/apps/package.json index 89d494e5b5..acbb073813 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -12,6 +12,8 @@ "./contracts": "./src/contracts/host.ts", "./host": "./src/host.ts", "./effect": "./src/effect.ts", + "./check": "./src/check.ts", + "./oxlint": "./src/oxlint.ts", "./operations/approval": "./src/approval.ts", "./client": "./src/client.ts", "./mcp": "./src/mcp.ts", diff --git a/packages/apps/scripts/build.mjs b/packages/apps/scripts/build.mjs index 04272ecb96..4034009d69 100644 --- a/packages/apps/scripts/build.mjs +++ b/packages/apps/scripts/build.mjs @@ -46,6 +46,7 @@ const compilerOptions = { skipLibCheck: true, declaration: true, emitDeclarationOnly: true, + allowJs: true, allowImportingTsExtensions: true, rootDir: packages, outDir: join(out, "types"), diff --git a/packages/apps/scripts/reference.mjs b/packages/apps/scripts/reference.mjs index de652d8d51..7a3d6c64a2 100644 --- a/packages/apps/scripts/reference.mjs +++ b/packages/apps/scripts/reference.mjs @@ -19,11 +19,13 @@ const modules = { "./openapi": "integrations.md", "./skills": "tools.md", }; -/** Subpaths for hosts and Effect libraries. Apps do not import them, so the reference omits them. */ +/** Host and authoring utility subpaths that do not describe the app runtime API. */ const hostModules = new Set([ "./contracts", "./host", "./effect", + "./check", + "./oxlint", "./mcp/effect", "./ui/contracts", "./ui/auth", diff --git a/packages/apps/src/check.ts b/packages/apps/src/check.ts new file mode 100644 index 0000000000..d65bce7939 --- /dev/null +++ b/packages/apps/src/check.ts @@ -0,0 +1,223 @@ +/** Prefix for static files that hosts serve without compiling. */ +export const publicSourcePrefix = "ui/public/"; +/** Whether a path names a static public file. */ +export const isPublicSourcePath = (path: string) => path.startsWith(publicSourcePrefix); + +/** Code files stay within the amount every host can hold while saving and building. */ +export const sourceLimits = { files: 4096, bytes: 16 * 1024 * 1024 } as const; +/** Public assets are served as-is and have their own storage limits. */ +export const publicFileLimits = { + files: 10_000, + fileBytes: 32_000_000, + bytes: 1_000_000_000, +} as const; + +export const sourceFits = (files: number, bytes: number): boolean => + Number.isSafeInteger(files) && + files >= 0 && + files <= sourceLimits.files && + Number.isSafeInteger(bytes) && + bytes >= 0 && + bytes <= sourceLimits.bytes; + +export const publicFits = (files: number, bytes: number, largest: number): boolean => + Number.isSafeInteger(files) && + files >= 0 && + files <= publicFileLimits.files && + Number.isSafeInteger(bytes) && + bytes >= 0 && + bytes <= publicFileLimits.bytes && + largest <= publicFileLimits.fileBytes; + +/** The path rules shared with the host's SourceFilePath schema. */ +export const sourcePathProblem = (path: string): string | undefined => { + if (path.includes("\0")) return "expected no NUL bytes"; + if (path.includes("\\")) return "expected POSIX separators"; + if (path.startsWith("/")) return "expected a relative path"; + return path.length > 0 && + path.split("/").every((part) => part !== "" && part !== "." && part !== "..") + ? undefined + : "expected canonical segments (non-empty, no `.` or `..`)"; +}; + +export type AppCheckFile = { + readonly path: string; + /** Code and manifests carry their exact text or bytes. Public files may use only `size`. */ + readonly content?: string | Uint8Array; + readonly size?: number; +}; + +export type AppCheckIssue = { + readonly code: string; + readonly message: string; + readonly path?: string; +}; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const exactVersion = (value: unknown): value is string => + typeof value === "string" && + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value); + +const contentBytes = (content: string | Uint8Array) => + typeof content === "string" ? encoder.encode(content).byteLength : content.byteLength; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Check the manifest and files without comparing an exact `apps` pin with a host release. */ +export function checkApp(files: readonly AppCheckFile[]): AppCheckIssue[] { + const issues: AppCheckIssue[] = []; + const paths = new Set(); + let codeFiles = 0; + let publicFiles = 0; + const publicSizes: number[] = []; + let codeBytes = 0; + let publicBytes = 0; + + for (const file of files) { + const pathProblem = sourcePathProblem(file.path); + if (pathProblem !== undefined) { + issues.push({ code: "source-path", message: pathProblem, path: file.path }); + continue; + } + if (paths.has(file.path)) { + issues.push({ code: "source-path", message: "duplicate path", path: file.path }); + continue; + } + paths.add(file.path); + + if (isPublicSourcePath(file.path)) { + publicFiles++; + const size = file.content === undefined ? file.size : contentBytes(file.content); + if (size === undefined || !Number.isSafeInteger(size) || size < 0) { + issues.push({ + code: "public-file-size", + message: "file size must be a non-negative integer", + path: file.path, + }); + continue; + } + publicSizes.push(size); + publicBytes += size; + if (size > publicFileLimits.fileBytes) + issues.push({ code: "public-file-size", message: "file exceeds 32 MB", path: file.path }); + continue; + } + + codeFiles++; + if (file.content === undefined) { + issues.push({ + code: "source-utf8", + message: "must be UTF-8 text outside ui/public/", + path: file.path, + }); + continue; + } + if (file.content instanceof Uint8Array) { + try { + decoder.decode(file.content); + } catch { + issues.push({ + code: "source-utf8", + message: "must be UTF-8 text outside ui/public/", + path: file.path, + }); + continue; + } + } + const size = contentBytes(file.content); + codeBytes += size; + } + + for (const path of paths) { + const segments = path.split("/"); + segments.pop(); + while (segments.length > 0) { + const parent = segments.join("/"); + if (paths.has(parent)) { + issues.push({ + code: "source-path", + message: "a file cannot also be a parent directory", + path, + }); + break; + } + segments.pop(); + } + } + + if (!paths.has("index.ts")) + issues.push({ code: "default-app-export", message: "missing index.ts", path: "index.ts" }); + + if (!sourceFits(codeFiles, codeBytes)) { + if (codeFiles > sourceLimits.files) + issues.push({ code: "source-file-count", message: "exceeds 4096 files" }); + if (codeBytes > sourceLimits.bytes) + issues.push({ code: "source-size", message: "exceeds 16 MiB" }); + } + + const largestPublic = publicSizes.reduce((largest, size) => Math.max(largest, size), 0); + if (!publicFits(publicFiles, publicBytes, largestPublic)) { + if (publicFiles > publicFileLimits.files) + issues.push({ code: "public-file-count", message: "exceeds 10000 files" }); + if (publicBytes > publicFileLimits.bytes) + issues.push({ code: "public-total-size", message: "exceeds 1 GB" }); + if ( + largestPublic > publicFileLimits.fileBytes && + !issues.some((issue) => issue.code === "public-file-size") + ) + issues.push({ code: "public-file-size", message: "a file exceeds 32 MB" }); + } + + const manifestFile = files.find((file) => file.path === "package.json"); + if (manifestFile?.content === undefined) { + issues.push({ + code: "manifest-module", + message: "package.json is required", + path: "package.json", + }); + } else { + let manifestText: string; + try { + manifestText = + typeof manifestFile.content === "string" + ? manifestFile.content + : decoder.decode(manifestFile.content); + } catch { + issues.push({ + code: "manifest-module", + message: "package.json must be UTF-8 text", + path: "package.json", + }); + return issues; + } + let manifest: unknown; + try { + manifest = JSON.parse(manifestText); + } catch { + issues.push({ + code: "manifest-module", + message: "package.json must contain valid JSON", + path: "package.json", + }); + return issues; + } + if (!isRecord(manifest) || manifest.type !== "module") + issues.push({ + code: "manifest-module", + message: "package.json must set type to module", + path: "package.json", + }); + const dependencies = + isRecord(manifest) && isRecord(manifest.dependencies) ? manifest.dependencies : {}; + if (!exactVersion(dependencies.apps)) + issues.push({ + code: "framework-pin", + message: "dependencies.apps must be an exact version", + path: "package.json", + }); + } + + return issues; +} diff --git a/packages/apps/src/oxlint.ts b/packages/apps/src/oxlint.ts new file mode 100644 index 0000000000..0e1f5b523d --- /dev/null +++ b/packages/apps/src/oxlint.ts @@ -0,0 +1,3 @@ +// @ts-ignore Oxlint's experimental JS plugin API has no stable TypeScript types. +export { default } from "./oxlint/plugin.mjs"; +export { appConfig } from "./oxlint/config.ts"; diff --git a/packages/apps/src/oxlint/ast.mjs b/packages/apps/src/oxlint/ast.mjs new file mode 100644 index 0000000000..917e9d5edd --- /dev/null +++ b/packages/apps/src/oxlint/ast.mjs @@ -0,0 +1,157 @@ +const expressionWrappers = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSSatisfiesExpression", + "TSNonNullExpression", + "TSTypeAssertion", + "TSInstantiationExpression", +]); + +export const frameworkFunctions = new Set(["defineApp", "mutation", "query", "decodeJson"]); + +export function unwrapExpression(node) { + while (node && expressionWrappers.has(node.type)) node = node.expression; + return node; +} + +export function walkAst(root, visit) { + const seen = new WeakSet(); + function walk(node) { + if (!node || typeof node !== "object" || typeof node.type !== "string" || seen.has(node)) + return; + seen.add(node); + visit(node); + for (const [key, value] of Object.entries(node)) { + if (key === "parent") continue; + if (Array.isArray(value)) { + for (const child of value) walk(child); + } else { + walk(value); + } + } + } + walk(root); +} + +export function propertyName(key, computed = false) { + if (!computed && key?.type === "Identifier") return key.name; + if (["Literal", "StringLiteral"].includes(key?.type) && typeof key.value === "string") + return key.value; + if (key?.type === "TemplateLiteral" && key.expressions?.length === 0) { + return key.quasis?.[0]?.value?.cooked ?? key.quasis?.[0]?.value?.raw; + } + return undefined; +} + +export function createBindings(context) { + const sourceCode = context.sourceCode; + const variableCache = new WeakMap(); + + function variableOf(identifier) { + if (!identifier || identifier.type !== "Identifier") return null; + if (variableCache.has(identifier)) return variableCache.get(identifier); + let variable = null; + try { + let scope = sourceCode.getScope(identifier); + while (scope && !variable) { + variable = scope.set?.get(identifier.name) ?? null; + scope = scope.upper; + } + } catch { + variable = null; + } + variableCache.set(identifier, variable); + return variable; + } + + function isUnmodified(variable) { + if (!variable || !Array.isArray(variable.references)) return false; + if (variable.references.some((reference) => typeof reference.isWrite !== "function")) + return false; + return variable.references.filter((reference) => reference.isWrite()).length <= 1; + } + + function variableDeclarator(variable) { + if (!variable || variable.defs?.length !== 1) return null; + const definition = variable.defs[0]; + const declarator = definition.node; + const kind = definition.parent?.kind; + if (definition.type !== "Variable" || !["const", "let", "var"].includes(kind)) return null; + if (declarator?.type !== "VariableDeclarator" || declarator.id?.type !== "Identifier") + return null; + if (declarator.id.name !== variable.name) return null; + return { declarator, kind }; + } + + function possibleValues(variable) { + const definition = variableDeclarator(variable); + if (!definition) return []; + const values = new Set(); + if (definition.declarator.init) values.add(definition.declarator.init); + for (const reference of variable.references ?? []) { + if (reference.isWrite() && reference.writeExpr) values.add(reference.writeExpr); + } + return [...values]; + } + + function localInitializer(variable) { + const values = possibleValues(variable); + return isUnmodified(variable) && values.length === 1 ? values[0] : null; + } + + function typeAlias(variable) { + for (const definition of variable?.defs ?? []) { + if (definition.node?.type === "TSTypeAliasDeclaration") return definition.node.typeAnnotation; + } + return null; + } + + // Framework identities have one supported source. The plain-bindings rule rejects + // escaping these imports, so helpers never need a second binding resolver. + function resolveSymbol(expression) { + const node = unwrapExpression(expression); + if (node?.type !== "Identifier") return null; + const variable = variableOf(node); + if (variable?.defs?.length !== 1) return null; + const definition = variable.defs[0]; + if (definition.type !== "ImportBinding" || definition.node?.type !== "ImportSpecifier") + return null; + const declaration = definition.parent ?? definition.node.parent; + if (declaration?.type !== "ImportDeclaration" || declaration.source.value !== "apps") + return null; + const imported = propertyName(definition.node.imported); + const contextType = ["QueryContext", "MutationContext"].includes(imported); + if ( + (declaration.importKind === "type" || definition.node.importKind === "type") && + !contextType + ) + return null; + return contextType || frameworkFunctions.has(imported) ? imported : null; + } + + function resolveFunctionNode(expression, seen = new Set()) { + const node = unwrapExpression(expression); + if (["ArrowFunctionExpression", "FunctionExpression"].includes(node?.type)) return node; + if (node?.type !== "Identifier") return null; + const variable = variableOf(node); + if (!variable || seen.has(variable)) return null; + seen.add(variable); + const initializer = unwrapExpression(localInitializer(variable)); + if (initializer) return resolveFunctionNode(initializer, seen); + for (const definition of variable.defs ?? []) { + if (definition.type === "FunctionName" && definition.node?.type === "FunctionDeclaration") { + return definition.node; + } + } + return null; + } + + return { + variableOf, + localInitializer, + typeAlias, + resolveSymbol, + resolveFunctionNode, + }; +} diff --git a/packages/apps/src/oxlint/config.ts b/packages/apps/src/oxlint/config.ts new file mode 100644 index 0000000000..0d5af5d51e --- /dev/null +++ b/packages/apps/src/oxlint/config.ts @@ -0,0 +1,85 @@ +/** Same rules for every app; only browser code has the browser's HTTP implementation. */ +export function appConfig(entryFile = "index.ts") { + return { + categories: { correctness: "off" }, + plugins: [], + jsPlugins: ["apps/oxlint"], + rules: { + "eslint/no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "effect", + "effect/**", + "@effect/**", + "@executor/**", + "@executor-js/**", + "apps/effect", + "apps/**/effect", + "@executor.sh/sdk", + "@executor.sh/sdk/**", + "@usefulsoftware/executor-sdk", + "@usefulsoftware/executor-sdk/**", + "node:module", + "module", + ], + message: + "Use ESM imports and the apps framework; CommonJS loaders, Effect and the Executor SDK are unsupported in app source.", + }, + ], + }, + ], + "eslint/no-restricted-globals": [ + "error", + { + name: "fetch", + message: "Use ctx.fetch (or a destructured context fetch) so the host owns HTTP.", + }, + { + name: "require", + message: "Use ESM imports; CommonJS loading is unsupported in app source.", + }, + { + name: "module", + message: "Use ESM imports; the CommonJS module global is unsupported in app source.", + }, + ], + "eslint/no-restricted-properties": [ + "error", + { object: "globalThis", property: "fetch", message: "Use ctx.fetch." }, + { object: "window", property: "fetch", message: "Use ctx.fetch." }, + { object: "self", property: "fetch", message: "Use ctx.fetch." }, + ], + "executor-apps/plain-framework-bindings": "error", + "executor-apps/default-app-export": ["error", { entryFile }], + "executor-apps/no-legacy-accounts": "error", + "executor-apps/no-global-fetch": "error", + "executor-apps/mutation-approval": "error", + }, + overrides: [ + { + files: ["**/ui/**"], + rules: { + "eslint/no-restricted-globals": [ + "error", + { + name: "require", + message: "Use ESM imports; CommonJS loading is unsupported in app source.", + }, + { + name: "module", + message: "Use ESM imports; the CommonJS module global is unsupported in app source.", + }, + ], + "eslint/no-restricted-properties": "off", + "executor-apps/default-app-export": "off", + "executor-apps/no-legacy-accounts": "off", + "executor-apps/no-global-fetch": "off", + "executor-apps/mutation-approval": "off", + }, + }, + ], + }; +} diff --git a/packages/apps/src/oxlint/global-fetch.mjs b/packages/apps/src/oxlint/global-fetch.mjs new file mode 100644 index 0000000000..1a87812783 --- /dev/null +++ b/packages/apps/src/oxlint/global-fetch.mjs @@ -0,0 +1,70 @@ +import { createBindings, propertyName, unwrapExpression, walkAst } from "./ast.mjs"; + +const globalObjects = new Set(["globalThis", "global", "window", "self"]); + +function variableValues(variable) { + const values = []; + for (const definition of variable?.defs ?? []) { + if (definition.node?.type === "VariableDeclarator" && definition.node.id?.type === "Identifier") + values.push(definition.node.init); + } + for (const reference of variable?.references ?? []) { + if (reference.isWrite() && reference.writeExpr) values.push(reference.writeExpr); + } + return values; +} + +export const noGlobalFetchRule = { + meta: { + type: "problem", + docs: { + description: "Require context HTTP rather than fetch reached through a global object alias.", + }, + schema: [], + messages: { + globalFetch: + "HTTP must use ctx.fetch or its destructured value; this fetch comes from a global object.", + }, + }, + create(context) { + const bindings = createBindings(context); + function isGlobalObject(expression, seen = new Set()) { + const node = unwrapExpression(expression); + if (node?.type === "Identifier") { + const variable = bindings.variableOf(node); + if (globalObjects.has(node.name) && !variable?.defs?.length) return true; + if (!variable || seen.has(variable)) return false; + seen.add(variable); + const result = variableValues(variable).some((value) => isGlobalObject(value, seen)); + seen.delete(variable); + return result; + } + return ( + node?.type === "MemberExpression" && + globalObjects.has(propertyName(node.property, node.computed)) && + isGlobalObject(node.object, seen) + ); + } + function inspectPattern(pattern, expression) { + if (pattern?.type !== "ObjectPattern" || !isGlobalObject(expression)) return; + for (const property of pattern.properties ?? []) { + if (propertyName(property.key, property.computed) === "fetch") + context.report({ node: property, messageId: "globalFetch" }); + } + } + return { + Program(program) { + walkAst(program, (node) => { + if ( + node.type === "MemberExpression" && + propertyName(node.property, node.computed) === "fetch" && + isGlobalObject(node.object) + ) + context.report({ node, messageId: "globalFetch" }); + if (node.type === "VariableDeclarator") inspectPattern(node.id, node.init); + if (node.type === "AssignmentExpression") inspectPattern(node.left, node.right); + }); + }, + }; + }, +}; diff --git a/packages/apps/src/oxlint/legacy-accounts.mjs b/packages/apps/src/oxlint/legacy-accounts.mjs new file mode 100644 index 0000000000..289529220d --- /dev/null +++ b/packages/apps/src/oxlint/legacy-accounts.mjs @@ -0,0 +1,205 @@ +import { createBindings, propertyName, unwrapExpression, walkAst } from "./ast.mjs"; +import { ruleMeta } from "./rule-meta.mjs"; + +function objectPropertyName(property) { + return propertyName(property.key, property.computed); +} + +function findTopLevelAccounts(expression, bindings, seen = new Set()) { + const node = unwrapExpression(expression); + if (node?.type === "ObjectExpression") { + for (const property of node.properties ?? []) { + if ( + ["ObjectProperty", "Property", "ObjectMethod"].includes(property.type) && + objectPropertyName(property) === "accounts" + ) { + return property; + } + if (property.type === "SpreadElement") { + const found = findTopLevelAccounts(property.argument, bindings, seen); + if (found) return found; + } + } + return null; + } + if (node?.type !== "Identifier") return null; + const variable = bindings.variableOf(node); + if (!variable || seen.has(variable)) return null; + const initializer = bindings.localInitializer(variable); + if (!initializer) return null; + seen.add(variable); + const found = findTopLevelAccounts(initializer, bindings, seen); + seen.delete(variable); + return found; +} + +function parameterPattern(parameter) { + let node = parameter; + while (node && ["AssignmentPattern", "RestElement", "TSParameterProperty"].includes(node.type)) { + node = node.left ?? node.argument ?? node.parameter; + } + return node; +} + +function parameterType(parameter) { + let node = parameter; + while (node && ["AssignmentPattern", "RestElement", "TSParameterProperty"].includes(node.type)) { + node = node.left ?? node.argument ?? node.parameter; + } + return node?.typeAnnotation?.typeAnnotation ?? null; +} + +function accountsProperty(pattern) { + if (pattern?.type !== "ObjectPattern") return null; + return ( + pattern.properties?.find( + (property) => + ["ObjectProperty", "Property"].includes(property.type) && + objectPropertyName(property) === "accounts", + ) ?? null + ); +} + +function isContextVariable(variable, bindings, marked, seen = new Set()) { + if (!variable || seen.has(variable)) return false; + if (marked.has(variable)) return true; + seen.add(variable); + const initializer = unwrapExpression(bindings.localInitializer(variable)); + const result = + initializer?.type === "Identifier" + ? isContextVariable(bindings.variableOf(initializer), bindings, marked, seen) + : false; + seen.delete(variable); + return result; +} + +function isFrameworkContextType(type, bindings, seen = new Set()) { + if (!type) return false; + if (type.type === "TSParenthesizedType") + return isFrameworkContextType(type.typeAnnotation, bindings, seen); + if (type.type === "TSIntersectionType") { + return (type.types ?? []).some((part) => isFrameworkContextType(part, bindings, seen)); + } + if (type.type !== "TSTypeReference") return false; + const name = type.typeName; + if (name?.type === "Identifier") { + const symbol = bindings.resolveSymbol(name); + if (symbol === "QueryContext" || symbol === "MutationContext") return true; + const variable = bindings.variableOf(name); + if (!variable || seen.has(variable)) return false; + seen.add(variable); + const alias = bindings.typeAlias(variable); + const result = isFrameworkContextType(alias, bindings, seen); + seen.delete(variable); + return result; + } + if (name?.type === "TSQualifiedName") { + const object = name.left; + return ( + bindings.resolveSymbol(object) === "namespace" && + ["QueryContext", "MutationContext"].includes(name.right?.name) + ); + } + return false; +} + +function noLegacyAccounts(context, program) { + const bindings = createBindings(context); + const contextVariables = new WeakSet(); + const reportedPatterns = new WeakSet(); + + function reportContextPattern(pattern) { + const property = accountsProperty(pattern); + if (property && !reportedPatterns.has(property)) { + reportedPatterns.add(property); + context.report({ node: property, messageId: "legacyContext" }); + } + } + + walkAst(program, (node) => { + if ( + ["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"].includes(node.type) + ) { + for (const parameter of node.params ?? []) { + if (!isFrameworkContextType(parameterType(parameter), bindings)) continue; + const pattern = parameterPattern(parameter); + if (pattern?.type === "Identifier") { + const variable = bindings.variableOf(pattern); + if (variable) contextVariables.add(variable); + } else { + reportContextPattern(pattern); + } + } + } + if (node.type !== "CallExpression") return; + const symbol = bindings.resolveSymbol(node.callee); + if (symbol === "defineApp") { + const property = findTopLevelAccounts(node.arguments?.[0], bindings); + if (property) context.report({ node: property, messageId: "legacyRequirements" }); + } + const handlerIndex = + symbol === "defineApp" || symbol === "query" || symbol === "mutation" ? 1 : -1; + if (handlerIndex < 0) return; + const handler = bindings.resolveFunctionNode(node.arguments?.[handlerIndex]); + const first = parameterPattern(handler?.params?.[0]); + if (first?.type === "Identifier") { + const variable = bindings.variableOf(first); + if (variable) contextVariables.add(variable); + } else { + reportContextPattern(first); + } + }); + + walkAst(program, (node) => { + const memberObject = node.type === "MemberExpression" ? unwrapExpression(node.object) : null; + if ( + node.type === "MemberExpression" && + memberObject?.type === "Identifier" && + propertyName(node.property, node.computed) === "accounts" && + isContextVariable(bindings.variableOf(memberObject), bindings, contextVariables) + ) { + context.report({ node, messageId: "legacyContext" }); + } + if ( + node.type === "VariableDeclarator" && + node.id?.type === "ObjectPattern" && + unwrapExpression(node.init)?.type === "Identifier" && + isContextVariable( + bindings.variableOf(unwrapExpression(node.init)), + bindings, + contextVariables, + ) + ) { + const property = accountsProperty(node.id); + if (property && !reportedPatterns.has(property)) { + reportedPatterns.add(property); + context.report({ node: property, messageId: "legacyContext" }); + } + } + if ( + node.type === "AssignmentExpression" && + node.left?.type === "ObjectPattern" && + unwrapExpression(node.right)?.type === "Identifier" && + isContextVariable( + bindings.variableOf(unwrapExpression(node.right)), + bindings, + contextVariables, + ) + ) { + const property = accountsProperty(node.left); + if (property && !reportedPatterns.has(property)) { + reportedPatterns.add(property); + context.report({ node: property, messageId: "legacyContext" }); + } + } + }); +} + +export const noLegacyAccountsRule = { + meta: ruleMeta( + "Reject legacy account requirements and direct account access from framework contexts.", + ), + create(context) { + return { Program: (program) => noLegacyAccounts(context, program) }; + }, +}; diff --git a/packages/apps/src/oxlint/plain-framework-bindings.mjs b/packages/apps/src/oxlint/plain-framework-bindings.mjs new file mode 100644 index 0000000000..a447c5bc12 --- /dev/null +++ b/packages/apps/src/oxlint/plain-framework-bindings.mjs @@ -0,0 +1,111 @@ +import { + createBindings, + frameworkFunctions, + propertyName, + unwrapExpression, + walkAst, +} from "./ast.mjs"; + +const contextTypes = new Set(["QueryContext", "MutationContext"]); +const guardedExports = new Set([...frameworkFunctions, ...contextTypes]); + +// Inspect each shipped module at its imports. Rejecting escapes here also covers +// consumers of local barrels, default exports and dynamic helper imports, without +// reading another module or inferring what an arbitrary helper returns. +export const plainFrameworkBindingsRule = { + meta: { + type: "problem", + docs: { description: "Keep guarded framework functions as direct named-import calls." }, + schema: [], + messages: { + plainBinding: + "Unsupported framework binding. Use named imports from `apps` and call `defineApp`, `mutation`, `query` and `decodeJson` directly. Do not create local aliases, destructure, re-export or pass these functions. Import `QueryContext` and `MutationContext` directly too; do not re-export these types or their local aliases.", + }, + }, + create(context) { + return { + Program(program) { + const bindings = createBindings(context); + let unsupported = null; + + function isContextType(node, seen = new Set()) { + if (!node) return false; + if (node.type === "TSParenthesizedType") return isContextType(node.typeAnnotation, seen); + if (node.type === "TSIntersectionType") + return node.types.some((part) => isContextType(part, seen)); + const name = node.type === "TSTypeReference" ? node.typeName : node; + if (name.type !== "Identifier") return false; + if (contextTypes.has(bindings.resolveSymbol(name))) return true; + const variable = bindings.variableOf(name); + if (!variable || seen.has(variable)) return false; + seen.add(variable); + return isContextType(bindings.typeAlias(variable), seen); + } + + function checkImport(node) { + if (node.source.value !== "apps") return; + for (const specifier of node.specifiers) { + if (specifier.type !== "ImportSpecifier") { + unsupported = specifier; + return; + } + if (node.importKind === "type" || specifier.importKind === "type") continue; + if (!frameworkFunctions.has(propertyName(specifier.imported))) continue; + const variable = bindings.variableOf(specifier.local); + if (!Array.isArray(variable?.references)) { + unsupported = specifier; + return; + } + for (const reference of variable.references) { + let value = reference.identifier; + // A type query observes the signature; it cannot forward the function. + if (value.parent?.type === "TSTypeQuery") continue; + while ( + value.parent?.expression === value && + unwrapExpression(value.parent) !== value.parent + ) + value = value.parent; + const call = value.parent; + if ( + reference.isWrite() || + call?.type !== "CallExpression" || + call.callee !== value || + call.optional + ) { + unsupported = reference.identifier; + return; + } + } + } + } + + walkAst(program, (node) => { + if (unsupported) return; + if (node.type === "ImportDeclaration") checkImport(node); + if (node.type === "ExportAllDeclaration" && node.source?.value === "apps") + unsupported = node; + if (node.type === "ExportNamedDeclaration" && node.source?.value === "apps") { + if (node.specifiers.some((item) => guardedExports.has(propertyName(item.local)))) + unsupported = node; + } + if (node.type === "ExportNamedDeclaration" && !node.source) { + if ( + node.specifiers.some((item) => isContextType(item.local)) || + (node.declaration?.type === "TSTypeAliasDeclaration" && + isContextType(node.declaration.typeAnnotation)) + ) + unsupported = node; + } + const source = + node.type === "ImportExpression" + ? node.source + : node.type === "CallExpression" && node.callee.type === "Import" + ? node.arguments[0] + : null; + if (propertyName(unwrapExpression(source), true) === "apps") unsupported = node; + }); + if (unsupported) context.report({ node: unsupported, messageId: "plainBinding" }); + }, + }; + }, +}; diff --git a/packages/apps/src/oxlint/plugin.mjs b/packages/apps/src/oxlint/plugin.mjs new file mode 100644 index 0000000000..2f9e5d4f2f --- /dev/null +++ b/packages/apps/src/oxlint/plugin.mjs @@ -0,0 +1,103 @@ +import { resolve } from "node:path"; +import { createBindings, propertyName, unwrapExpression, walkAst } from "./ast.mjs"; + +import { ruleMeta } from "./rule-meta.mjs"; +import { noLegacyAccountsRule } from "./legacy-accounts.mjs"; +import { noGlobalFetchRule } from "./global-fetch.mjs"; +import { plainFrameworkBindingsRule } from "./plain-framework-bindings.mjs"; + +const defaultAppExport = { + meta: { + ...ruleMeta("Require the resolved root app module to default-export `defineApp` from `apps`."), + schema: [ + { + type: "object", + properties: { entryFile: { type: "string" } }, + additionalProperties: false, + }, + ], + }, + create(context) { + const entryFile = context.options[0]?.entryFile; + if (entryFile && resolve(context.cwd, context.filename) !== resolve(context.cwd, entryFile)) + return {}; + return { + Program(program) { + const exported = program.body.find((node) => node.type === "ExportDefaultDeclaration"); + const value = unwrapExpression(exported?.declaration); + if ( + value?.type !== "CallExpression" || + createBindings(context).resolveSymbol(value.callee) !== "defineApp" + ) { + context.report({ node: exported?.declaration ?? program, messageId: "defaultApp" }); + } + }, + }; + }, +}; + +function objectPropertyName(property) { + return propertyName(property.key, property.computed); +} + +function analyzeMutationOptions(expression, bindings, seen = new Set()) { + const node = unwrapExpression(expression); + if (node?.type === "ObjectExpression") { + let state = { kind: "missing", node }; + for (const property of node.properties ?? []) { + if (property.type === "SpreadElement") { + const result = analyzeMutationOptions(property.argument, bindings, seen); + if (result.kind !== "missing") state = result; + } else if (["ObjectProperty", "Property", "ObjectMethod"].includes(property.type)) { + const name = objectPropertyName(property); + if (name === "approval") state = { kind: "explicit", node: property }; + else if (property.computed && name === undefined) + state = { kind: "unknown", node: property }; + } + } + return state; + } + if (node?.type !== "Identifier") return { kind: "unknown", node }; + const variable = bindings.variableOf(node); + if (!variable || seen.has(variable)) return { kind: "unknown", node }; + const initializer = bindings.localInitializer(variable); + if (!initializer) return { kind: "unknown", node }; + seen.add(variable); + const result = analyzeMutationOptions(initializer, bindings, seen); + seen.delete(variable); + return result; +} + +const mutationApproval = { + meta: ruleMeta("Require explicit approval ownership on every imported `mutation` call."), + create(context) { + return { + Program(program) { + const bindings = createBindings(context); + walkAst(program, (node) => { + if (node.type !== "CallExpression") return; + const symbol = bindings.resolveSymbol(node.callee); + if (symbol !== "mutation") return; + const options = analyzeMutationOptions(node.arguments?.[0], bindings); + if (options.kind !== "explicit") { + context.report({ + node: node.arguments?.[0] ?? node.callee, + messageId: options.kind === "unknown" ? "unknownApproval" : "missingApproval", + }); + } + }); + }, + }; + }, +}; + +export default { + meta: { name: "executor-apps" }, + rules: { + "plain-framework-bindings": plainFrameworkBindingsRule, + "default-app-export": defaultAppExport, + "no-legacy-accounts": noLegacyAccountsRule, + "mutation-approval": mutationApproval, + "no-global-fetch": noGlobalFetchRule, + }, +}; diff --git a/packages/apps/src/oxlint/rule-meta.mjs b/packages/apps/src/oxlint/rule-meta.mjs new file mode 100644 index 0000000000..a6c9127b4b --- /dev/null +++ b/packages/apps/src/oxlint/rule-meta.mjs @@ -0,0 +1,16 @@ +const messages = { + defaultApp: + "Use `export default defineApp(...)` directly, with `defineApp` imported from `apps` by a named import.", + legacyRequirements: + "Legacy top-level `accounts` requirements are unsupported; declare accounts under `user` or `app`.", + legacyContext: + "Read accounts through `ctx.user.accounts` or `ctx.app.accounts`, not directly from framework context.", + missingApproval: + "Every `mutation` must explicitly declare `approval`; use `approval: undefined` for an explicit no-approval choice.", + unknownApproval: + "Cannot prove this mutation's `approval`; use a local const options object with an explicit `approval` property.", +}; + +export function ruleMeta(description) { + return { type: "problem", docs: { description }, schema: [], messages }; +} diff --git a/packages/sdk/src/contracts/source.ts b/packages/sdk/src/contracts/source.ts index 4e6ddc5206..e043141a59 100644 --- a/packages/sdk/src/contracts/source.ts +++ b/packages/sdk/src/contracts/source.ts @@ -1,39 +1,40 @@ /** Durable app source lives in a host-owned revision store, independently of SQL and builds. */ import { Effect, Result, Schema } from "effect"; import { Base64 } from "effect/encoding"; +import { + checkApp, + isPublicSourcePath, + publicFits, + publicSourcePrefix, + sourceFits, + sourcePathProblem, +} from "apps/check"; import { ApiError } from "@executor-js/utils/api-error"; import { AppCodeId, type AppId, type OwnerId, type StorageError } from "./shared.ts"; import type { AppNotFound } from "./apps.ts"; +export { + checkApp, + isPublicSourcePath, + publicFileLimits, + publicFits, + publicSourcePrefix, + sourceFits, + sourceLimits, +} from "apps/check"; +export type { AppCheckFile, AppCheckIssue } from "apps/check"; + /** * Canonical relative POSIX path inside a version: no absolute paths, * backslashes, NUL bytes, or empty/`.`/`..` segments. Plain refined * strings — callers need not brand every path. */ export const SourceFilePath = Schema.String.pipe( - Schema.check( - Schema.makeFilter((path: string) => { - if (path.includes("\0")) return "expected no NUL bytes"; - if (path.includes("\\")) return "expected POSIX separators"; - if (path.startsWith("/")) return "expected a relative path"; - return path.length > 0 && path.split("/").every((s) => s !== "" && s !== "." && s !== "..") - ? true - : "expected canonical segments (non-empty, no `.` or `..`)"; - }), - ), + Schema.check(Schema.makeFilter((path: string) => sourcePathProblem(path) ?? true)), ); export type SourceFilePath = typeof SourceFilePath.Type; -/** - * Browser files served as they are, at the app page's origin. They are the only source that may - * hold bytes that are not UTF-8 text: the host never compiles, evaluates or reads them, and builds - * do not load them. Deploys copy them to the host's own store, from which they are served. - */ -export const publicSourcePrefix = "ui/public/"; -/** Whether a source path is a public file under `ui/public/`. */ -export const isPublicSourcePath = (path: string) => path.startsWith(publicSourcePrefix); - /** A Git blob's object ID: the SHA-1 of `blob \0` and its bytes. */ export const GitObjectId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{40}$/u)).annotate({ description: "Git blob object ID: 40 lowercase hex characters.", @@ -388,43 +389,6 @@ export const Branch = Schema.String.check( Schema.isPattern(/^[a-zA-Z0-9_][a-zA-Z0-9/_-]{0,127}$/u), Schema.makeFilter((value) => !value.endsWith("/") && !value.includes("//")), ); -/** - * What one source operation holds in memory, identical for complete writes and incremental reads - * on every host: every code file (outside `ui/public/`) and any public file it carries inline. - * Saves, builds and deploys hold these in a Worker's 128 MB, so they stay bounded. Public files a - * read lists by their blob (`SourceStoredFile`) are not held and do not count here. - */ -export const sourceLimits = { files: 4096, bytes: 16 * 1024 * 1024 } as const; -/** - * Public files under `ui/public/`, which builds never load and deploys copy one at a time: - * - * - `fileBytes`: Cloudflare Artifacts, which stores hosted app source, refuses a file over 32 MB. - * - `bytes`: an Artifacts repository holds at most 1 GB, so no commit's files can exceed it. - * - `files`: each version lists its public files in one object read when they are served, and - * each file is a few storage operations at deploy; 10,000 keeps both small. - */ -export const publicFileLimits = { - files: 10_000, - fileBytes: 32_000_000, - bytes: 1_000_000_000, -} as const; -/** Reject invalid counters and stop readers before they accumulate an oversized source tree. */ -export const sourceFits = (files: number, bytes: number): boolean => - Number.isSafeInteger(files) && - files >= 0 && - files <= sourceLimits.files && - Number.isSafeInteger(bytes) && - bytes >= 0 && - bytes <= sourceLimits.bytes; -/** Whether public files of these counts fit, each at most `fileBytes`. */ -export const publicFits = (files: number, bytes: number, largest: number): boolean => - Number.isSafeInteger(files) && - files >= 0 && - files <= publicFileLimits.files && - Number.isSafeInteger(bytes) && - bytes >= 0 && - bytes <= publicFileLimits.bytes && - largest <= publicFileLimits.fileBytes; /** Recent source history is metadata, separate from the files at each immutable revision. */ export const GitCommit = Schema.Struct({ commit: SourceCommit,