From fb60a7c4f94b48f672631a1f6de9c5da0981cf92 Mon Sep 17 00:00:00 2001 From: Sree Narayanan Date: Fri, 26 Jun 2026 23:59:13 +0400 Subject: [PATCH] Add Sideffect lint plugin --- .changeset/README.md | 9 +- .changeset/config.json | 2 +- .changeset/soft-ravens-lint.md | 5 + .github/workflows/lint.yml | 27 + .github/workflows/publish.yml | 64 +- .github/workflows/test.yml | 6 +- bun.lock | 177 ++- packages/lint-plugin/README.md | 305 ++++ packages/lint-plugin/package.json | 95 ++ packages/lint-plugin/src/configs.ts | 21 + packages/lint-plugin/src/eslint.ts | 43 + packages/lint-plugin/src/index.ts | 5 + packages/lint-plugin/src/oxlint.ts | 2 + packages/lint-plugin/src/plugin.ts | 25 + .../lint-plugin/src/rules/await-step-calls.ts | 40 + .../src/rules/deterministic-step-names.ts | 53 + packages/lint-plugin/src/rules/index.ts | 5 + .../src/rules/max-step-timeout-30m.ts | 51 + .../src/rules/no-event-mutation.ts | 66 + .../rules/no-step-race-any-outside-step.ts | 50 + packages/lint-plugin/src/shared/ast.ts | 1235 +++++++++++++++++ packages/lint-plugin/src/shared/duration.ts | 45 + packages/lint-plugin/src/types.ts | 61 + packages/lint-plugin/tests/rules.test.ts | 695 ++++++++++ packages/lint-plugin/tsconfig.json | 19 + packages/lint-plugin/vite.config.ts | 27 + packages/sideffect/package.json | 3 +- vite.config.ts | 8 + .../cloudflare-workflows-shared/tsconfig.json | 6 + 29 files changed, 3099 insertions(+), 51 deletions(-) create mode 100644 .changeset/soft-ravens-lint.md create mode 100644 .github/workflows/lint.yml create mode 100644 packages/lint-plugin/README.md create mode 100644 packages/lint-plugin/package.json create mode 100644 packages/lint-plugin/src/configs.ts create mode 100644 packages/lint-plugin/src/eslint.ts create mode 100644 packages/lint-plugin/src/index.ts create mode 100644 packages/lint-plugin/src/oxlint.ts create mode 100644 packages/lint-plugin/src/plugin.ts create mode 100644 packages/lint-plugin/src/rules/await-step-calls.ts create mode 100644 packages/lint-plugin/src/rules/deterministic-step-names.ts create mode 100644 packages/lint-plugin/src/rules/index.ts create mode 100644 packages/lint-plugin/src/rules/max-step-timeout-30m.ts create mode 100644 packages/lint-plugin/src/rules/no-event-mutation.ts create mode 100644 packages/lint-plugin/src/rules/no-step-race-any-outside-step.ts create mode 100644 packages/lint-plugin/src/shared/ast.ts create mode 100644 packages/lint-plugin/src/shared/duration.ts create mode 100644 packages/lint-plugin/src/types.ts create mode 100644 packages/lint-plugin/tests/rules.test.ts create mode 100644 packages/lint-plugin/tsconfig.json create mode 100644 packages/lint-plugin/vite.config.ts diff --git a/.changeset/README.md b/.changeset/README.md index 0de2498..4e1ef66 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,9 +1,12 @@ # Changesets -Use `bun run changeset` in PRs that affect the published `sideffect` package. +Use `bun run changeset` in PRs that affect either published Sideffect package: + +- `sideffect` +- `@sideffect/lint` Use `bun run changeset:status` to inspect pending release notes. -Use `bun run changeset:version` to prepare a release commit that updates `packages/sideffect/package.json` and `packages/sideffect/CHANGELOG.md`. +Use `bun run changeset:version` to prepare a release commit. `sideffect` and `@sideffect/lint` are a fixed release set and must keep the same version number. -Publishing is explicit: manually dispatch the publish workflow and type the exact `publish sideffect@` confirmation. +Publishing is explicit: manually dispatch the publish workflow and type the exact `publish sideffect@ and @sideffect/lint@` confirmation. diff --git a/.changeset/config.json b/.changeset/config.json index f3c0a13..2fc9cc3 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], + "fixed": [["sideffect", "@sideffect/lint"]], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.changeset/soft-ravens-lint.md b/.changeset/soft-ravens-lint.md new file mode 100644 index 0000000..2f51fe6 --- /dev/null +++ b/.changeset/soft-ravens-lint.md @@ -0,0 +1,5 @@ +--- +"@sideffect/lint": patch +--- + +Add ESLint and Oxlint rules for Cloudflare Workflows and Sideffect workflow syntax. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..2996a3d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install deps + run: bun install --frozen-lockfile + + - name: Check + run: bun run check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ca66377..8ab5b48 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,10 +4,10 @@ on: workflow_dispatch: inputs: version: - description: Exact sideffect version to publish, e.g. 0.2.2 + description: Exact Sideffect release-set version to publish, e.g. 0.2.2 required: true confirm: - description: Type publish sideffect@ + description: Type publish sideffect@ and @sideffect/lint@ required: true npm_tag: description: npm dist-tag @@ -47,11 +47,19 @@ jobs: INPUT_CONFIRM: ${{ inputs.confirm }} INPUT_VERSION: ${{ inputs.version }} run: | - package_version="$(node -p "require('./packages/sideffect/package.json').version")" - expected_confirm="publish sideffect@${package_version}" + sideffect_version="$(node -p "require('./packages/sideffect/package.json').version")" + lint_version="$(node -p "require('./packages/lint-plugin/package.json').version")" + + if [ "${sideffect_version}" != "${lint_version}" ]; then + printf 'Refusing to publish: packages/sideffect/package.json version "%s" does not match packages/lint-plugin/package.json version "%s". Run bun run changeset:version and commit the generated release changes before publishing.\n' "${sideffect_version}" "${lint_version}" >&2 + exit 1 + fi + + package_version="${sideffect_version}" + expected_confirm="publish sideffect@${package_version} and @sideffect/lint@${package_version}" if [ "${INPUT_VERSION}" != "${package_version}" ]; then - printf 'Refusing to publish: workflow input version "%s" does not match packages/sideffect/package.json version "%s". Run bun run changeset:version and dispatch this workflow with the exact package version.\n' "${INPUT_VERSION}" "${package_version}" >&2 + printf 'Refusing to publish: workflow input version "%s" does not match the Sideffect release-set version "%s". Run bun run changeset:version and dispatch this workflow with the exact package version.\n' "${INPUT_VERSION}" "${package_version}" >&2 exit 1 fi @@ -71,35 +79,37 @@ jobs: exit 1 fi + if npm view "@sideffect/lint@${package_version}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + printf 'Refusing to publish: @sideffect/lint@%s already exists on npm. The registry was not changed.\n' "${package_version}" >&2 + exit 1 + fi + - name: Install deps run: bun install --frozen-lockfile - - name: Check sideffect - working-directory: packages/sideffect + - name: Check packages run: bun run check - - name: Test sideffect - working-directory: packages/sideffect + - name: Test packages run: bun run test - # Build before publish so npm validates package.json after bin targets exist. - - name: Build sideffect - working-directory: packages/sideffect + # Build before publish so npm validates package.json after package outputs exist. + - name: Build packages run: bun run build - name: Run Cloudflare workflow E2E run: bun run test:e2e - - name: Pack sideffect - working-directory: packages/sideffect + - name: Pack release set run: | - mkdir -p ../../release-artifacts - npm pack --pack-destination ../../release-artifacts + mkdir -p release-artifacts + npm pack ./packages/sideffect --pack-destination release-artifacts + npm pack ./packages/lint-plugin --pack-destination release-artifacts - uses: actions/upload-artifact@v4 with: - name: sideffect-package - path: release-artifacts/sideffect-*.tgz + name: sideffect-packages + path: release-artifacts/*.tgz if-no-files-found: error retention-days: 1 @@ -119,11 +129,23 @@ jobs: - uses: actions/download-artifact@v4 with: - name: sideffect-package + name: sideffect-packages path: release-artifacts - - name: Publish sideffect (OIDC, no token) + - name: Publish release set (OIDC, no token) env: INPUT_VERSION: ${{ inputs.version }} NPM_TAG: ${{ inputs.npm_tag }} - run: npm publish "release-artifacts/sideffect-${INPUT_VERSION}.tgz" --provenance --tag "${NPM_TAG}" --ignore-scripts + run: | + if npm view "sideffect@${INPUT_VERSION}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + printf 'Refusing to publish: sideffect@%s already exists on npm. The registry was not changed.\n' "${INPUT_VERSION}" >&2 + exit 1 + fi + + if npm view "@sideffect/lint@${INPUT_VERSION}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + printf 'Refusing to publish: @sideffect/lint@%s already exists on npm. The registry was not changed.\n' "${INPUT_VERSION}" >&2 + exit 1 + fi + + npm publish "release-artifacts/sideffect-${INPUT_VERSION}.tgz" --provenance --tag "${NPM_TAG}" --ignore-scripts + npm publish "release-artifacts/sideffect-lint-${INPUT_VERSION}.tgz" --provenance --tag "${NPM_TAG}" --ignore-scripts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 486f992..fee4989 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,12 +23,8 @@ jobs: - name: Install deps run: bun install --frozen-lockfile - - name: Build sideffect - working-directory: packages/sideffect + - name: Build packages run: bun run build - - name: Check - run: bun run check - - name: Test packages run: bun run test diff --git a/bun.lock b/bun.lock index 6f0b58d..9be93db 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,25 @@ "vite-plus": "catalog:", }, }, + "packages/lint-plugin": { + "name": "@sideffect/lint", + "version": "0.2.1", + "devDependencies": { + "@types/node": "25.6.2", + "eslint": "^9.39.4", + "oxlint": "^1.67.0", + "typescript": "catalog:", + "vite-plus": "catalog:", + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "oxlint": ">=1.67.0", + }, + "optionalPeers": [ + "eslint", + "oxlint", + ], + }, "packages/sideffect": { "name": "sideffect", "version": "0.2.1", @@ -311,8 +330,36 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -649,6 +696,8 @@ "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="], + "@sideffect/lint": ["@sideffect/lint@workspace:packages/lint-plugin"], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], @@ -791,7 +840,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -891,9 +940,11 @@ "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], @@ -921,6 +972,8 @@ "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], @@ -929,6 +982,8 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -939,6 +994,8 @@ "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -967,6 +1024,8 @@ "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -993,6 +1052,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -1033,16 +1094,28 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1059,6 +1132,10 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -1067,11 +1144,17 @@ "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], @@ -1085,10 +1168,12 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], "goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="], @@ -1117,6 +1202,10 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -1155,9 +1244,13 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1165,12 +1258,16 @@ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1197,7 +1294,9 @@ "loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], @@ -1231,6 +1330,8 @@ "miniflare": ["miniflare@4.20260611.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.24.8", "workerd": "1.20260611.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1247,6 +1348,8 @@ "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], @@ -1261,6 +1364,8 @@ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxc-parser": ["oxc-parser@0.120.0", "", { "dependencies": { "@oxc-project/types": "^0.120.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.120.0", "@oxc-parser/binding-android-arm64": "0.120.0", "@oxc-parser/binding-darwin-arm64": "0.120.0", "@oxc-parser/binding-darwin-x64": "0.120.0", "@oxc-parser/binding-freebsd-x64": "0.120.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", "@oxc-parser/binding-linux-arm64-musl": "0.120.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-musl": "0.120.0", "@oxc-parser/binding-openharmony-arm64": "0.120.0", "@oxc-parser/binding-wasm32-wasi": "0.120.0", "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", "@oxc-parser/binding-win32-x64-msvc": "0.120.0" } }, "sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w=="], @@ -1273,9 +1378,9 @@ "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], - "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], @@ -1283,6 +1388,8 @@ "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1319,6 +1426,8 @@ "postprocessing": ["postprocessing@6.39.1", "", { "peerDependencies": { "three": ">= 0.168.0 < 0.185.0" } }, "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -1423,6 +1532,8 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1471,6 +1582,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -1481,7 +1594,7 @@ "undici": ["undici@7.27.2", "", {}, "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -1491,6 +1604,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -1527,6 +1642,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "workerd": ["workerd@1.20260611.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260611.1", "@cloudflare/workerd-darwin-arm64": "1.20260611.1", "@cloudflare/workerd-linux-64": "1.20260611.1", "@cloudflare/workerd-linux-arm64": "1.20260611.1", "@cloudflare/workerd-windows-64": "1.20260611.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w=="], "wrangler": ["wrangler@4.100.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260611.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260611.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260611.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg=="], @@ -1551,6 +1668,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -1565,8 +1684,12 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + "@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="], @@ -1625,13 +1748,19 @@ "@tanstack/start-plugin-core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-keywords/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "bumpp/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "cloudflare-workflows-tanstack/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "jest-worker/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -1649,7 +1778,7 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "sideffect/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "schema-utils/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1657,8 +1786,6 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "video/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - "video/@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "video/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -1667,8 +1794,12 @@ "video/tailwindcss": ["tailwindcss@4.0.0", "", {}, "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ=="], + "webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "@remotion/bundler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@remotion/bundler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -1753,13 +1884,19 @@ "@tailwindcss/webpack/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ=="], - "jest-worker/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "cloudflare-workflows-tanstack/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "sideffect/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "video/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + + "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@remotion/renderer/source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], @@ -1802,5 +1939,7 @@ "@tailwindcss/webpack/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/webpack/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@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=="], } } diff --git a/packages/lint-plugin/README.md b/packages/lint-plugin/README.md new file mode 100644 index 0000000..df6b7a1 --- /dev/null +++ b/packages/lint-plugin/README.md @@ -0,0 +1,305 @@ +# @sideffect/lint + +Lint rules for Cloudflare Workflows and Sideffect workflows. + +`@sideffect/lint` catches workflow bugs that are easy to miss in review: unowned step promises, mutation of replayed event payloads, unsafe races between steps, unstable step names, and `step.do` timeouts that exceed Cloudflare's recommended limit. + +The same rules run in ESLint and Oxlint, and they work with both native Cloudflare `WorkflowEntrypoint` classes and Sideffect `Workflow.make(...).toLayer(...)` definitions. + +> This package is experimental. Rule behavior and public exports may change before `1.0`. + +## Install + +```sh +bun add -D @sideffect/lint +``` + +Install the runner you use as well: + +```sh +bun add -D eslint +# or +bun add -D oxlint +``` + +`eslint` and `oxlint` are optional peer dependencies so you only need the one your project runs. + +## Quick Start + +### ESLint + +Use the recommended flat config: + +```js +// eslint.config.js +import sideffect from "@sideffect/lint/eslint"; + +export default [...sideffect.configs.recommended]; +``` + +Use `strict` if you also want step-name determinism and timeout checks: + +```js +// eslint.config.js +import sideffect from "@sideffect/lint/eslint"; + +export default [...sideffect.configs.strict]; +``` + +### Oxlint + +Register the JavaScript plugin and enable the rules you want: + +```jsonc +// .oxlintrc.json +{ + "jsPlugins": [ + { + "name": "sideffect", + "specifier": "@sideffect/lint/oxlint", + }, + ], + "rules": { + "sideffect/await-step-calls": "error", + "sideffect/no-event-mutation": "error", + "sideffect/no-step-race-any-outside-step": "warn", + }, +} +``` + +For Vite+ projects, you can import the bundled Oxlint preset: + +```ts +// vite.config.ts +import { oxlintRecommended } from "@sideffect/lint"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + lint: oxlintRecommended, +}); +``` + +Use `oxlintStrict` to enable every rule: + +```ts +// vite.config.ts +import { oxlintStrict } from "@sideffect/lint"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + lint: oxlintStrict, +}); +``` + +## Presets + +`recommended` enables the rules that protect core workflow correctness: + +```txt +sideffect/await-step-calls: error +sideffect/no-event-mutation: error +sideffect/no-step-race-any-outside-step: warn +``` + +`strict` includes `recommended` and adds rules for deploy-time hygiene: + +```txt +sideffect/deterministic-step-names: warn +sideffect/max-step-timeout-30m: error +``` + +## Supported Workflow Shapes + +### Native Cloudflare Workflows + +Rules run inside `run(event, step)` methods on classes that extend `WorkflowEntrypoint` from `cloudflare:workers`: + +```ts +import { WorkflowEntrypoint } from "cloudflare:workers"; + +export class BillingWorkflow extends WorkflowEntrypoint { + async run(event, step) { + await step.do("charge customer", async () => { + return chargeCustomer(event.payload.customerId); + }); + } +} +``` + +Namespace imports are supported too: + +```ts +import * as cf from "cloudflare:workers"; + +export class BillingWorkflow extends cf.WorkflowEntrypoint { + async run(event, step) { + return step.sleep("pause", "1 second"); + } +} +``` + +### Sideffect Workflows + +Rules also run inside Sideffect workflow layers: + +```ts +import { Schema, Step, Workflow } from "sideffect"; + +const billingWorkflow = Workflow.make({ + name: "billing", + payload: Schema.Struct({ customerId: Schema.String }), +}); + +const chargeCustomer = Step.make("charge customer", { + payload: Schema.Struct({ customerId: Schema.String }), + result: Schema.Void, + run: async ({ customerId }, ctx) => { + await ctx.env.PAYMENTS.charge(customerId); + }, +}); + +export const billingWorkflowLayer = billingWorkflow.toLayer(async ({ payload }, step) => { + await step.do(chargeCustomer, payload); +}); +``` + +The plugin recognizes direct `Workflow.make(...).toLayer(...)` calls, local workflow constants, exported workflow constants, and imported local definitions whose names end in `Workflow`. + +Effect-style layer bodies are supported when the step promise is owned by `Effect.promise` or `Effect.tryPromise`: + +```ts +Workflow.make({ name: "billing" }).toLayer( + Effect.fn(function* ({ payload }, step) { + return yield* Effect.promise(() => step.do(chargeCustomer, payload)); + }), +); +``` + +## Rules + +### `sideffect/await-step-calls` + +Workflow step calls must be owned. A call to `step.do`, `step.sleep`, `step.sleepUntil`, or `step.waitForEvent` should be awaited, returned, yielded, chained from a returned promise, or collected by an awaited promise aggregate. + +```ts +// Bad: the workflow can continue before the step is tracked. +step.do("charge customer", async () => chargeCustomer(event.payload.customerId)); + +// Good: the workflow owns the step promise. +await step.do("charge customer", async () => chargeCustomer(event.payload.customerId)); +``` + +Owned promise aggregates are accepted: + +```ts +const [customer, invoice] = await Promise.all([ + step.do("fetch customer", async () => fetchCustomer(event.payload.customerId)), + step.do("fetch invoice", async () => fetchInvoice(event.payload.invoiceId)), +]); +``` + +The rule accepts `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` when the aggregate itself is owned. Pair it with `no-step-race-any-outside-step` if you want to block replay-unsafe races. + +### `sideffect/no-event-mutation` + +Workflow events and payloads are replay inputs. Mutating them can make retries, restarts, and local reasoning disagree about what state actually exists. + +```ts +// Bad: mutates replay input. +event.payload.status = "charged"; + +// Good: derive a new value and persist it through a step. +const nextStatus = await step.do("charge", async () => "charged"); +``` + +The rule catches direct assignments, updates, deletes, and common mutating methods through simple aliases: + +```ts +const { payload } = event; + +payload.items.push("charged"); +delete payload.pendingChargeId; +``` + +### `sideffect/no-step-race-any-outside-step` + +`Promise.race` and `Promise.any` over step promises can replay inconsistently when the race happens in workflow code. Put the race inside a `step.do` callback so the result is captured as one step outcome. + +```ts +// Bad: races workflow steps directly. +const winner = await Promise.race([ + step.do("slow provider", async () => callSlowProvider()), + step.do("fast provider", async () => callFastProvider()), +]); + +// Good: captures the race result inside one step. +const winner = await step.do("race providers", async () => { + return Promise.race([callSlowProvider(), callFastProvider()]); +}); +``` + +### `sideffect/deterministic-step-names` + +Step names are stable identifiers for persisted workflow state. Do not build them from time, randomness, or generated IDs. + +```ts +// Bad: creates a different step name on replay. +await step.do(`run at ${Date.now()}`, async () => event.payload); + +// Good: stable name, variable data stays in the payload. +await step.do("process payload", async () => event.payload); +``` + +The rule checks names passed to native step calls and names passed to `Step.make(...)`. It detects `Date.now()`, `new Date()`, `Math.random()`, and `crypto.randomUUID()`. + +### `sideffect/max-step-timeout-30m` + +Cloudflare recommends `step.do` timeouts of 30 minutes or less. Use `step.waitForEvent` or split the workflow when work needs to wait longer. + +```ts +// Bad: longer than 30 minutes. +await step.do("call provider", { timeout: "2 hours" }, async () => callProvider()); + +// Good: within the recommended limit. +await step.do("call provider", { timeout: "30 minutes" }, async () => callProvider()); +``` + +For Sideffect step definitions, the timeout object is the third argument: + +```ts +await step.do(chargeCustomer, payload, { timeout: "30 minutes" }); +``` + +The rule handles string durations, static template literals, and numeric millisecond literals. + +## Manual Rule Configuration + +If you do not want a preset, register the plugin and choose rules directly. + +```js +// eslint.config.js +import sideffect from "@sideffect/lint/eslint"; + +export default [ + { + plugins: { sideffect }, + rules: { + "sideffect/await-step-calls": "error", + "sideffect/no-event-mutation": "error", + "sideffect/no-step-race-any-outside-step": "warn", + "sideffect/deterministic-step-names": "warn", + "sideffect/max-step-timeout-30m": "error", + }, + }, +]; +``` + +## Limitations + +`@sideffect/lint` is syntax-only. It does not run TypeScript type resolution, execute modules, or prove that arbitrary functions are deterministic. + +That keeps the plugin fast and lets the same rule implementations run in both ESLint and Oxlint, but it also means recognition is intentionally conservative. Imported local workflow definitions are detected with syntax and naming heuristics rather than full runtime value tracking. + +## License + +Apache-2.0 diff --git a/packages/lint-plugin/package.json b/packages/lint-plugin/package.json new file mode 100644 index 0000000..91a1115 --- /dev/null +++ b/packages/lint-plugin/package.json @@ -0,0 +1,95 @@ +{ + "name": "@sideffect/lint", + "version": "0.2.1", + "description": "ESLint and Oxlint rules for Sideffect and Cloudflare Workflows.", + "keywords": [ + "cloudflare-workers", + "eslint", + "eslint-plugin", + "eslintplugin", + "oxlint", + "workflows" + ], + "homepage": "https://github.com/eersnington/sideffect#readme", + "bugs": { + "url": "https://github.com/eersnington/sideffect/issues" + }, + "license": "Apache-2.0", + "author": "Eersnington", + "repository": { + "type": "git", + "url": "git+https://github.com/eersnington/sideffect.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./eslint": { + "types": "./dist/eslint.d.mts", + "default": "./dist/eslint.mjs" + }, + "./oxlint": { + "types": "./dist/oxlint.d.mts", + "default": "./dist/oxlint.mjs" + }, + "./rules": { + "types": "./dist/rules/index.d.mts", + "default": "./dist/rules/index.mjs" + }, + "./rules/await-step-calls": { + "types": "./dist/rules/await-step-calls.d.mts", + "default": "./dist/rules/await-step-calls.mjs" + }, + "./rules/deterministic-step-names": { + "types": "./dist/rules/deterministic-step-names.d.mts", + "default": "./dist/rules/deterministic-step-names.mjs" + }, + "./rules/max-step-timeout-30m": { + "types": "./dist/rules/max-step-timeout-30m.d.mts", + "default": "./dist/rules/max-step-timeout-30m.mjs" + }, + "./rules/no-event-mutation": { + "types": "./dist/rules/no-event-mutation.d.mts", + "default": "./dist/rules/no-event-mutation.mjs" + }, + "./rules/no-step-race-any-outside-step": { + "types": "./dist/rules/no-step-race-any-outside-step.d.mts", + "default": "./dist/rules/no-step-race-any-outside-step.mjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vp pack", + "test": "vp test", + "check": "vp check", + "prepublishOnly": "vp pack" + }, + "devDependencies": { + "@types/node": "25.6.2", + "eslint": "^9.39.4", + "oxlint": "^1.67.0", + "typescript": "catalog:", + "vite-plus": "catalog:" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "oxlint": ">=1.67.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "oxlint": { + "optional": true + } + } +} diff --git a/packages/lint-plugin/src/configs.ts b/packages/lint-plugin/src/configs.ts new file mode 100644 index 0000000..2e9c6b8 --- /dev/null +++ b/packages/lint-plugin/src/configs.ts @@ -0,0 +1,21 @@ +export const recommendedRules = { + "sideffect/await-step-calls": "error", + "sideffect/no-event-mutation": "error", + "sideffect/no-step-race-any-outside-step": "warn", +} as const; + +export const strictRules = { + ...recommendedRules, + "sideffect/deterministic-step-names": "warn", + "sideffect/max-step-timeout-30m": "error", +} as const; + +export const oxlintRecommended = { + jsPlugins: [{ name: "sideffect", specifier: "@sideffect/lint/oxlint" }], + rules: recommendedRules, +}; + +export const oxlintStrict = { + jsPlugins: [{ name: "sideffect", specifier: "@sideffect/lint/oxlint" }], + rules: strictRules, +}; diff --git a/packages/lint-plugin/src/eslint.ts b/packages/lint-plugin/src/eslint.ts new file mode 100644 index 0000000..2b818ba --- /dev/null +++ b/packages/lint-plugin/src/eslint.ts @@ -0,0 +1,43 @@ +import { recommendedRules, strictRules } from "./configs.ts"; +import { basePlugin, type SideffectLintPlugin } from "./plugin.ts"; + +type SideffectFlatConfig = { + readonly plugins: { readonly sideffect: SideffectLintPlugin }; + readonly rules: typeof recommendedRules | typeof strictRules; +}; + +type SideffectESLintPlugin = SideffectLintPlugin & { + readonly configs: { + readonly recommended: ReadonlyArray; + readonly strict: ReadonlyArray; + }; +}; + +const eslintPlugin: SideffectESLintPlugin = { + ...basePlugin, + configs: { + get recommended() { + return eslintRecommended; + }, + get strict() { + return eslintStrict; + }, + }, +}; + +export const eslintRecommended = [ + { + plugins: { sideffect: eslintPlugin }, + rules: recommendedRules, + }, +]; + +export const eslintStrict = [ + { + plugins: { sideffect: eslintPlugin }, + rules: strictRules, + }, +]; + +export default eslintPlugin; +export { rules } from "./plugin.ts"; diff --git a/packages/lint-plugin/src/index.ts b/packages/lint-plugin/src/index.ts new file mode 100644 index 0000000..8ab3f1e --- /dev/null +++ b/packages/lint-plugin/src/index.ts @@ -0,0 +1,5 @@ +export { recommendedRules, strictRules, oxlintRecommended, oxlintStrict } from "./configs.ts"; +export { eslintRecommended, eslintStrict } from "./eslint.ts"; +export { default, default as eslintPlugin } from "./eslint.ts"; +export { basePlugin as oxlintPlugin, rules } from "./plugin.ts"; +export type { SideffectLintPlugin } from "./plugin.ts"; diff --git a/packages/lint-plugin/src/oxlint.ts b/packages/lint-plugin/src/oxlint.ts new file mode 100644 index 0000000..d899235 --- /dev/null +++ b/packages/lint-plugin/src/oxlint.ts @@ -0,0 +1,2 @@ +export { oxlintRecommended, oxlintStrict } from "./configs.ts"; +export { basePlugin as default, rules } from "./plugin.ts"; diff --git a/packages/lint-plugin/src/plugin.ts b/packages/lint-plugin/src/plugin.ts new file mode 100644 index 0000000..2be39d7 --- /dev/null +++ b/packages/lint-plugin/src/plugin.ts @@ -0,0 +1,25 @@ +import { + awaitStepCallsRule, + deterministicStepNamesRule, + maxStepTimeout30mRule, + noEventMutationRule, + noStepRaceAnyOutsideStepRule, +} from "./rules/index.ts"; + +export const rules = { + "await-step-calls": awaitStepCallsRule, + "no-event-mutation": noEventMutationRule, + "no-step-race-any-outside-step": noStepRaceAnyOutsideStepRule, + "deterministic-step-names": deterministicStepNamesRule, + "max-step-timeout-30m": maxStepTimeout30mRule, +}; + +export const basePlugin = { + meta: { + name: "@sideffect/lint", + namespace: "sideffect", + }, + rules, +}; + +export type SideffectLintPlugin = typeof basePlugin; diff --git a/packages/lint-plugin/src/rules/await-step-calls.ts b/packages/lint-plugin/src/rules/await-step-calls.ts new file mode 100644 index 0000000..91d2ae4 --- /dev/null +++ b/packages/lint-plugin/src/rules/await-step-calls.ts @@ -0,0 +1,40 @@ +import { + createProgramState, + findWorkflowContext, + initializeProgramState, + isConsumedStepCall, + isStepCall, +} from "../shared/ast.ts"; +import type { Node, RuleContext, WorkflowRule, WorkflowVisitor } from "../types.ts"; + +export const awaitStepCallsRule: WorkflowRule = { + meta: { + type: "problem", + docs: { description: "Require Workflow step calls to be awaited or returned." }, + messages: { + danglingStepCall: + "Workflow step call is not awaited, returned, or included in an awaited Promise aggregation.", + }, + schema: [], + }, + create: createAwaitStepCalls, + createOnce: createAwaitStepCalls, +}; + +function createAwaitStepCalls(context: RuleContext): WorkflowVisitor { + let state = createProgramState(); + + return { + Program(node: Node) { + state = initializeProgramState(node); + }, + CallExpression(node: Node) { + const workflow = findWorkflowContext(context, node, state); + if (!workflow || !isStepCall(node, workflow) || isConsumedStepCall(context, node, workflow)) { + return; + } + + context.report({ node: node as never, messageId: "danglingStepCall" }); + }, + } as unknown as WorkflowVisitor; +} diff --git a/packages/lint-plugin/src/rules/deterministic-step-names.ts b/packages/lint-plugin/src/rules/deterministic-step-names.ts new file mode 100644 index 0000000..3cbfb7d --- /dev/null +++ b/packages/lint-plugin/src/rules/deterministic-step-names.ts @@ -0,0 +1,53 @@ +import { + createProgramState, + findWorkflowContext, + hasNondeterministicExpression, + initializeProgramState, + isStepCall, + isStepMakeCall, + nameArgumentForStepCall, +} from "../shared/ast.ts"; +import type { Node, RuleContext, WorkflowRule, WorkflowVisitor } from "../types.ts"; + +export const deterministicStepNamesRule: WorkflowRule = { + meta: { + type: "problem", + docs: { description: "Disallow obvious nondeterminism in Workflow step names." }, + messages: { + nondeterministicStepName: + "Workflow step names must be deterministic so cached step state can be reused after retries and restarts.", + }, + schema: [], + }, + create: createDeterministicStepNames, + createOnce: createDeterministicStepNames, +}; + +function createDeterministicStepNames(context: RuleContext): WorkflowVisitor { + let state = createProgramState(); + + return { + Program(node: Node) { + state = initializeProgramState(node); + }, + CallExpression(node: Node) { + const workflow = findWorkflowContext(context, node, state); + if (isStepCall(node, workflow)) { + const name = workflow ? nameArgumentForStepCall(node, workflow) : undefined; + if (hasNondeterministicExpression(name)) { + context.report({ node: name as never, messageId: "nondeterministicStepName" }); + } + return; + } + + if (!isStepMakeCall(node, state)) { + return; + } + const args = Array.isArray(node.arguments) ? (node.arguments as Array) : []; + const name = args[0]; + if (hasNondeterministicExpression(name)) { + context.report({ node: name as never, messageId: "nondeterministicStepName" }); + } + }, + } as unknown as WorkflowVisitor; +} diff --git a/packages/lint-plugin/src/rules/index.ts b/packages/lint-plugin/src/rules/index.ts new file mode 100644 index 0000000..a6f8d35 --- /dev/null +++ b/packages/lint-plugin/src/rules/index.ts @@ -0,0 +1,5 @@ +export { awaitStepCallsRule } from "./await-step-calls.ts"; +export { deterministicStepNamesRule } from "./deterministic-step-names.ts"; +export { maxStepTimeout30mRule } from "./max-step-timeout-30m.ts"; +export { noEventMutationRule } from "./no-event-mutation.ts"; +export { noStepRaceAnyOutsideStepRule } from "./no-step-race-any-outside-step.ts"; diff --git a/packages/lint-plugin/src/rules/max-step-timeout-30m.ts b/packages/lint-plugin/src/rules/max-step-timeout-30m.ts new file mode 100644 index 0000000..a16eaff --- /dev/null +++ b/packages/lint-plugin/src/rules/max-step-timeout-30m.ts @@ -0,0 +1,51 @@ +import { parseDurationMilliseconds } from "../shared/duration.ts"; +import { + createProgramState, + findWorkflowContext, + initializeProgramState, + isStepCall, + literalDurationValue, + stepDoTimeoutNode, +} from "../shared/ast.ts"; +import type { Node, RuleContext, WorkflowRule, WorkflowVisitor } from "../types.ts"; + +const maxStepTimeoutMilliseconds = 30 * 60 * 1_000; + +export const maxStepTimeout30mRule: WorkflowRule = { + meta: { + type: "problem", + docs: { description: "Limit Workflow step.do timeouts to 30 minutes or less." }, + messages: { + timeoutTooLong: + "Workflow step.do timeout is longer than 30 minutes. Use a timeout of 30 minutes or less, or model long waits with waitForEvent.", + }, + schema: [], + }, + create: createMaxStepTimeout30m, + createOnce: createMaxStepTimeout30m, +}; + +function createMaxStepTimeout30m(context: RuleContext): WorkflowVisitor { + let state = createProgramState(); + + return { + Program(node: Node) { + state = initializeProgramState(node); + }, + CallExpression(node: Node) { + const workflow = findWorkflowContext(context, node, state); + if (!isStepCall(node, workflow) || !workflow) { + return; + } + + const timeout = stepDoTimeoutNode(node, workflow); + const literal = literalDurationValue(timeout); + const duration = literal !== undefined ? parseDurationMilliseconds(literal) : undefined; + if (duration === undefined || duration <= maxStepTimeoutMilliseconds) { + return; + } + + context.report({ node: timeout as never, messageId: "timeoutTooLong" }); + }, + } as unknown as WorkflowVisitor; +} diff --git a/packages/lint-plugin/src/rules/no-event-mutation.ts b/packages/lint-plugin/src/rules/no-event-mutation.ts new file mode 100644 index 0000000..5e44d11 --- /dev/null +++ b/packages/lint-plugin/src/rules/no-event-mutation.ts @@ -0,0 +1,66 @@ +import { + createProgramState, + findWorkflowContext, + isEventMutatingCall, + initializeProgramState, + isEventMutationTarget, +} from "../shared/ast.ts"; +import type { Node, RuleContext, WorkflowRule, WorkflowVisitor } from "../types.ts"; + +export const noEventMutationRule: WorkflowRule = { + meta: { + type: "problem", + docs: { description: "Disallow mutating Workflow event and payload state." }, + messages: { + eventMutation: + "Workflow event and payload values are immutable across retries and restarts. Return new step state instead of mutating them.", + }, + schema: [], + }, + create: createNoEventMutation, + createOnce: createNoEventMutation, +}; + +function createNoEventMutation(context: RuleContext): WorkflowVisitor { + let state = createProgramState(); + + return { + Program(node: Node) { + state = initializeProgramState(node); + }, + AssignmentExpression(node: Node) { + const workflow = findWorkflowContext(context, node, state); + const left = node.left as Node | undefined; + if (!workflow || !left || !isEventMutationTarget(context, left, workflow)) { + return; + } + context.report({ node: left as never, messageId: "eventMutation" }); + }, + CallExpression(node: Node) { + const workflow = findWorkflowContext(context, node, state); + if (!workflow || !isEventMutatingCall(context, node, workflow)) { + return; + } + context.report({ node: node as never, messageId: "eventMutation" }); + }, + UpdateExpression(node: Node) { + const workflow = findWorkflowContext(context, node, state); + const argument = node.argument as Node | undefined; + if (!workflow || !argument || !isEventMutationTarget(context, argument, workflow)) { + return; + } + context.report({ node: argument as never, messageId: "eventMutation" }); + }, + UnaryExpression(node: Node) { + if (node.operator !== "delete") { + return; + } + const workflow = findWorkflowContext(context, node, state); + const argument = node.argument as Node | undefined; + if (!workflow || !argument || !isEventMutationTarget(context, argument, workflow)) { + return; + } + context.report({ node: argument as never, messageId: "eventMutation" }); + }, + } as unknown as WorkflowVisitor; +} diff --git a/packages/lint-plugin/src/rules/no-step-race-any-outside-step.ts b/packages/lint-plugin/src/rules/no-step-race-any-outside-step.ts new file mode 100644 index 0000000..8ac8313 --- /dev/null +++ b/packages/lint-plugin/src/rules/no-step-race-any-outside-step.ts @@ -0,0 +1,50 @@ +import { + containsStepCall, + createProgramState, + findWorkflowContext, + initializeProgramState, + isInsideStepDoCallback, + isPromiseRaceOrAnyCall, +} from "../shared/ast.ts"; +import type { Node, RuleContext, WorkflowRule, WorkflowVisitor } from "../types.ts"; + +export const noStepRaceAnyOutsideStepRule: WorkflowRule = { + meta: { + type: "problem", + docs: { description: "Require Promise.race and Promise.any over steps to run inside a step." }, + messages: { + stepRaceOutsideStep: + "Promise.race or Promise.any over Workflow steps can replay inconsistently. Wrap the race in a step.do callback.", + }, + schema: [], + }, + create: createNoStepRaceAnyOutsideStep, + createOnce: createNoStepRaceAnyOutsideStep, +}; + +function createNoStepRaceAnyOutsideStep(context: RuleContext): WorkflowVisitor { + let state = createProgramState(); + + return { + Program(node: Node) { + state = initializeProgramState(node); + }, + CallExpression(node: Node) { + if (!isPromiseRaceOrAnyCall(node)) { + return; + } + + const workflow = findWorkflowContext(context, node, state); + if (!workflow || isInsideStepDoCallback(context, node, workflow)) { + return; + } + + const args = Array.isArray(node.arguments) ? (node.arguments as Array) : []; + if (!args.some((arg) => containsStepCall(arg, workflow))) { + return; + } + + context.report({ node: node as never, messageId: "stepRaceOutsideStep" }); + }, + } as unknown as WorkflowVisitor; +} diff --git a/packages/lint-plugin/src/shared/ast.ts b/packages/lint-plugin/src/shared/ast.ts new file mode 100644 index 0000000..a9edec0 --- /dev/null +++ b/packages/lint-plugin/src/shared/ast.ts @@ -0,0 +1,1235 @@ +import type { Node, RuleContext, WorkflowContextInfo } from "../types.ts"; + +export const stepMethodNames = new Set(["do", "sleep", "sleepUntil", "waitForEvent"]); + +export interface ImportAliases { + readonly workflowEntrypoint: Set; + readonly workflow: Set; + readonly step: Set; + readonly sideffectNamespaces: Set; + readonly cloudflareNamespaces: Set; +} + +export interface ProgramState { + readonly imports: ImportAliases; + readonly workflowDefinitions: Set; +} + +export function createProgramState(): ProgramState { + return { + imports: { + workflowEntrypoint: new Set(), + workflow: new Set(), + step: new Set(), + sideffectNamespaces: new Set(), + cloudflareNamespaces: new Set(), + }, + workflowDefinitions: new Set(), + }; +} + +export function initializeProgramState(program: Node): ProgramState { + const state = createProgramState(); + for (const statement of nodeList(program.body)) { + if (statement.type === "ImportDeclaration") { + collectImportAliases(statement, state.imports); + continue; + } + + const declarationStatement = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + + if (declarationStatement?.type !== "VariableDeclaration") { + continue; + } + + for (const declaration of declarationStatement.declarations ?? []) { + const id = declaration.id; + const initializer = declaration.init; + if (isIdentifier(id) && initializer && isWorkflowMakeCall(initializer, state)) { + state.workflowDefinitions.add(id.name); + } + } + } + return state; +} + +export function getAncestors(context: RuleContext, node: Node): Array { + return context.sourceCode.getAncestors(node as never) as unknown as Array; +} + +export function findWorkflowContext( + context: RuleContext, + node: Node, + state: ProgramState, +): WorkflowContextInfo | undefined { + const ancestors = getAncestors(context, node); + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + const ancestor = ancestors[index]; + if (!isFunctionLike(ancestor)) { + continue; + } + + const native = nativeWorkflowContext(ancestor, ancestors, index, state); + if (native) { + return withWorkflowAliases(native, node); + } + + const sideffect = sideffectWorkflowContext(ancestor, ancestors, index, state); + if (sideffect) { + return withWorkflowAliases(sideffect, node); + } + } +} + +export function isStepCall(call: Node, workflow: WorkflowContextInfo | undefined): boolean { + if (!workflow || call.type !== "CallExpression") { + return false; + } + + const callee = unwrapExpression(call.callee); + if (callee?.type !== "MemberExpression" || callee.computed) { + return false; + } + + const object = unwrapExpression(callee.object); + const property = callee.property; + return ( + isIdentifier(object) && + object.name === workflow.stepName && + isIdentifier(property) && + stepMethodNames.has(property.name) + ); +} + +export function stepCallMethod(call: Node): string | undefined { + const callee = unwrapExpression(call.callee); + if (callee?.type !== "MemberExpression") { + return; + } + const property = callee.property; + return isIdentifier(property) ? property.name : undefined; +} + +export function isPromiseRaceOrAnyCall(call: Node): boolean { + if (call.type !== "CallExpression") { + return false; + } + const callee = unwrapExpression(call.callee); + if (callee?.type !== "MemberExpression") { + return false; + } + const object = callee.object; + const property = callee.property; + return ( + isIdentifier(object) && + object.name === "Promise" && + isIdentifier(property) && + ["race", "any"].includes(property.name) + ); +} + +export function containsStepCall(node: Node | undefined, workflow: WorkflowContextInfo): boolean { + if (!node) { + return false; + } + + if (node.type === "CallExpression" && containsStepCallInEagerCallback(node, workflow)) { + return true; + } + + if (isFunctionLike(node) || node.type === "ClassDeclaration" || node.type === "ClassExpression") { + return false; + } + + if (node.type === "CallExpression" && isStepCall(node, workflow)) { + return true; + } + + for (const child of childNodes(node)) { + if (containsStepCall(child, workflow)) { + return true; + } + } + + return false; +} + +export function isInsideStepDoCallback( + context: RuleContext, + node: Node, + workflow: WorkflowContextInfo, +): boolean { + const ancestors = getAncestors(context, node); + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + const ancestor = ancestors[index]; + if (!isFunctionLike(ancestor)) { + continue; + } + + const parent = ancestors[index - 1]; + if (parent?.type !== "CallExpression") { + continue; + } + + if (isStepCall(parent, workflow) && stepCallMethod(parent) === "do") { + return true; + } + } + return false; +} + +export function isConsumedStepCall( + context: RuleContext, + call: Node, + workflow: WorkflowContextInfo, +): boolean { + return isOwnedExpression(context, call, workflow); +} + +export function isOwnedExpression( + context: RuleContext, + expression: Node, + workflow: WorkflowContextInfo, +): boolean { + const ancestors = getAncestors(context, expression); + const parent = ancestors.at(-1); + if (!parent) { + return false; + } + + if (parent.type === "AwaitExpression" || parent.type === "YieldExpression") { + return isEnclosingFunctionOwned(context, expression, workflow); + } + + if (parent.type === "ReturnStatement") { + const effectCall = ancestors.findLast(isEffectPromiseCall); + if (effectCall && isReturnedFromEffectCallback(ancestors, effectCall)) { + return isOwnedEffectCall(context, effectCall, workflow); + } + + return nearestFunction(ancestors) === workflow.functionNode; + } + + if (parent.type === "MemberExpression") { + const chainCall = ancestors.at(-2); + if (chainCall?.type !== "CallExpression") { + return false; + } + const method = propertyName(parent.property); + if (method === "then" || method === "catch" || method === "finally") { + return isOwnedAncestorExpression(context, chainCall, workflow); + } + } + + if (parent.type === "ArrowFunctionExpression" && parent.body === expression) { + if (parent === workflow.functionNode || isInsideStepDoCallback(context, expression, workflow)) { + return true; + } + + const effectCall = ancestors.findLast(isEffectPromiseCall); + if (effectCall && isOwnedEffectCall(context, effectCall, workflow)) { + return true; + } + } + + if (parent.type === "ArrayExpression") { + const callParent = ancestors.at(-2); + if (callParent?.type !== "CallExpression") { + return false; + } + const callee = unwrapExpression(callParent.callee); + if (callee?.type !== "MemberExpression") { + return false; + } + const object = callee.object; + const property = callee.property; + const method = isIdentifier(property) ? property.name : undefined; + if ( + isIdentifier(object) && + object.name === "Promise" && + (method === "all" || method === "allSettled" || method === "race" || method === "any") + ) { + return isOwnedAncestorExpression(context, callParent, workflow); + } + } + + return false; +} + +export function isEventMutationTarget( + context: RuleContext, + target: Node, + workflow: WorkflowContextInfo, +): boolean { + if (isIdentifier(target)) { + return false; + } + + const base = memberBase(target); + if (!isIdentifier(base)) { + return false; + } + + const bindingKind = protectedBindingKindAt(context, base, workflow); + if (bindingKind) { + return true; + } + if (isShadowedProtectedName(context, base, workflow)) { + return false; + } + + const baseName = base.name; + if (workflow.eventNames.has(baseName) || workflow.payloadNames.has(baseName)) { + return true; + } + + const path = memberPath(target); + if (path.length < 2) { + return false; + } + if (path[0] !== baseName) { + return false; + } + + return ( + matchesProtectedPath(path, workflow.eventNames) || + matchesProtectedPath(path, workflow.payloadNames) + ); +} + +export function isEventMutatingCall( + context: RuleContext, + call: Node, + workflow: WorkflowContextInfo, +): boolean { + const callee = unwrapExpression(call.callee); + if (callee?.type !== "MemberExpression" || callee.computed) { + return false; + } + + if (memberPath(callee).join(".") === "Object.assign") { + const target = nodeList(call.arguments)[0]; + return target ? isEventMutationObjectTarget(context, target, workflow) : false; + } + + const method = propertyName(callee.property); + if (!method || !mutatingMethodNames.has(method)) { + return false; + } + + return isNode(callee.object) && isEventMutationTarget(context, callee.object, workflow); +} + +function isEventMutationObjectTarget( + context: RuleContext, + target: Node, + workflow: WorkflowContextInfo, +): boolean { + if (!isIdentifier(target)) { + return isEventMutationTarget(context, target, workflow); + } + const bindingKind = protectedBindingKindAt(context, target, workflow); + if (bindingKind) { + return true; + } + if (isShadowedProtectedName(context, target, workflow)) { + return false; + } + return workflow.eventNames.has(target.name) || workflow.payloadNames.has(target.name); +} + +export function nameArgumentForStepCall( + call: Node, + workflow: WorkflowContextInfo, +): Node | undefined { + const args = nodeList(call.arguments); + const method = stepCallMethod(call); + if (workflow.kind === "native") { + return args[0]; + } + if (method === "sleep" || method === "sleepUntil" || method === "waitForEvent") { + return args[0]; + } +} + +export function isStepMakeCall(call: Node, state: ProgramState): boolean { + const callee = unwrapExpression(call.callee); + if (callee?.type !== "MemberExpression") { + return false; + } + const property = callee.property; + if (!isIdentifier(property) || property.name !== "make") { + return false; + } + const object = unwrapExpression(callee.object); + if (isIdentifier(object)) { + return state.imports.step.has(object.name); + } + return isNamespaceMember(object, state.imports.sideffectNamespaces, "Step"); +} + +export function hasNondeterministicExpression(node: Node | undefined): boolean { + if (!node) { + return false; + } + + if (node.type === "CallExpression") { + const callee = unwrapExpression(node.callee); + const path = memberPath(callee); + if ( + (isIdentifier(callee) && callee.name === "Date") || + path.join(".") === "Date.now" || + path.join(".") === "Math.random" || + path.join(".") === "crypto.randomUUID" || + path.join(".") === "globalThis.crypto.randomUUID" + ) { + return true; + } + } + + if (node.type === "NewExpression") { + const callee = unwrapExpression(node.callee); + if (isIdentifier(callee) && callee.name === "Date") { + return true; + } + } + + for (const child of childNodes(node)) { + if (hasNondeterministicExpression(child)) { + return true; + } + } + + return false; +} + +export function stepDoTimeoutNode(call: Node, workflow: WorkflowContextInfo): Node | undefined { + if (stepCallMethod(call) !== "do") { + return; + } + const args = nodeList(call.arguments); + const config = workflow.kind === "native" ? args[1] : args[2]; + if (!config || config.type !== "ObjectExpression") { + return; + } + + for (const property of config.properties ?? []) { + if (property.type !== "Property") { + continue; + } + if (propertyName(property.key) !== "timeout") { + continue; + } + return isNode(property.value) ? property.value : undefined; + } +} + +export function literalString(node: Node | undefined): string | undefined { + if (!node) { + return; + } + if (node?.type === "Literal" && typeof node.value === "string") { + return node.value; + } + if (node.type === "TemplateLiteral" && (node.expressions ?? []).length === 0) { + const quasi = nodeList(node.quasis)[0]; + const value = quasi?.value; + if (isTemplateValue(value)) { + return value.cooked || value.raw; + } + } +} + +export function literalDurationValue(node: Node | undefined): string | number | undefined { + if ( + node?.type === "Literal" && + (typeof node.value === "string" || typeof node.value === "number") + ) { + return node.value; + } + return literalString(node); +} + +function collectImportAliases(statement: Node, imports: ImportAliases): void { + if (statement.source?.type !== "Literal") { + return; + } + + const module = String(statement.source.value); + for (const specifier of nodeList(statement.specifiers)) { + const local = specifier.local; + if (!isIdentifier(local)) { + continue; + } + const localName = local.name; + + if (module === "sideffect" && specifier.type === "ImportNamespaceSpecifier") { + imports.sideffectNamespaces.add(localName); + continue; + } + + if (module === "cloudflare:workers" && specifier.type === "ImportNamespaceSpecifier") { + imports.cloudflareNamespaces.add(localName); + continue; + } + + if (isLocalSpecifier(module) && /workflow$/i.test(localName)) { + // Syntax-only local import tracking for workflow definitions exported from sibling modules. + imports.workflow.add(localName); + continue; + } + + const imported = specifier.imported; + if (!isIdentifier(imported)) { + continue; + } + const importedName = imported.name; + if (module === "cloudflare:workers" && importedName === "WorkflowEntrypoint") { + imports.workflowEntrypoint.add(localName); + } + if (module === "sideffect" && importedName === "Workflow") { + imports.workflow.add(localName); + } + if (module === "sideffect" && importedName === "Step") { + imports.step.add(localName); + } + } +} + +function nativeWorkflowContext( + fn: Node, + ancestors: Array, + index: number, + state: ProgramState, +): WorkflowContextInfo | undefined { + const method = ancestors[index - 1]; + const classBody = ancestors[index - 2]; + const classNode = ancestors[index - 3]; + if (method?.type !== "MethodDefinition" || classBody?.type !== "ClassBody" || !classNode) { + return; + } + if (propertyName(method.key) !== "run") { + return; + } + const superClass = unwrapExpression(classNode.superClass ?? undefined); + if (!isWorkflowEntrypoint(superClass, state)) { + return; + } + const [eventParam, stepParam] = nodeList(fn.params); + if (!isIdentifier(eventParam) || !isIdentifier(stepParam)) { + return; + } + const event = eventParam.name; + const step = stepParam.name; + return { + kind: "native", + functionNode: fn, + eventNames: new Set([event]), + payloadNames: new Set([`${event}.payload`]), + stepName: step, + }; +} + +function sideffectWorkflowContext( + fn: Node, + ancestors: Array, + index: number, + state: ProgramState, +): WorkflowContextInfo | undefined { + const callbackCall = ancestors[index - 1]; + if (callbackCall?.type !== "CallExpression") { + return; + } + + const call = isEffectFnCall(callbackCall) ? ancestors[index - 2] : callbackCall; + const callbackArgument = isEffectFnCall(callbackCall) ? callbackCall : fn; + if (call?.type !== "CallExpression" || call.arguments?.[0] !== callbackArgument) { + return; + } + const callee = unwrapExpression(call.callee); + if (callee?.type !== "MemberExpression" || propertyName(callee.property) !== "toLayer") { + return; + } + const receiver = unwrapExpression(callee.object); + const isWorkflow = + isWorkflowMakeCall(receiver, state) || + (isIdentifier(receiver) && + (state.workflowDefinitions.has(receiver.name) || state.imports.workflow.has(receiver.name))); + if (!isWorkflow) { + return; + } + + const params = nodeList(fn.params); + const stepParam = params[1]; + if (!isIdentifier(stepParam)) { + return; + } + const step = stepParam.name; + + const eventNames = new Set(); + const payloadNames = new Set(); + const workflowParam = params[0]; + if (isIdentifier(workflowParam)) { + const name = workflowParam.name; + eventNames.add(`${name}.event`); + payloadNames.add(`${name}.payload`); + payloadNames.add(`${name}.event.payload`); + } + if (workflowParam?.type === "ObjectPattern") { + for (const property of nodeList(workflowParam.properties)) { + if (property.type !== "Property") { + continue; + } + const key = propertyName(property.key); + const value = isNode(property.value) ? property.value : undefined; + if (key === "event" && isIdentifier(value)) { + eventNames.add(value.name); + payloadNames.add(`${value.name}.payload`); + } + if (key === "payload" && isIdentifier(value)) { + payloadNames.add(value.name); + } + } + } + + return { kind: "sideffect", functionNode: fn, eventNames, payloadNames, stepName: step }; +} + +function withWorkflowAliases( + workflow: WorkflowContextInfo, + referenceNode: Node, +): WorkflowContextInfo { + const eventNames = new Set(workflow.eventNames); + const payloadNames = new Set(workflow.payloadNames); + const aliasedWorkflow = { ...workflow, eventNames, payloadNames }; + const referenceStart = nodeStart(referenceNode); + + collectWorkflowAliases( + workflow.functionNode, + aliasedWorkflow, + eventNames, + payloadNames, + referenceStart, + ); + + return aliasedWorkflow; +} + +function collectWorkflowAliases( + node: Node, + workflow: WorkflowContextInfo, + eventNames: Set, + payloadNames: Set, + referenceStart: number | undefined, +): void { + for (const child of childNodes(node)) { + if ( + child !== node && + (isFunctionLike(child) || + child.type === "ClassDeclaration" || + child.type === "ClassExpression") + ) { + continue; + } + + if (child.type === "VariableDeclarator") { + const declarationStart = nodeStart(child); + if ( + referenceStart === undefined || + declarationStart === undefined || + declarationStart < referenceStart + ) { + collectVariableAliases(child, workflow, eventNames, payloadNames); + } + } + + collectWorkflowAliases(child, workflow, eventNames, payloadNames, referenceStart); + } +} + +function collectVariableAliases( + declaration: Node, + workflow: WorkflowContextInfo, + eventNames: Set, + payloadNames: Set, +): void { + const id = declaration.id; + const initializer = isNode(declaration.init) ? declaration.init : undefined; + if (!id || !initializer) { + return; + } + + if (isIdentifier(id)) { + const kind = protectedPathKind(initializer, workflow); + if (kind === "event") { + eventNames.add(id.name); + payloadNames.add(`${id.name}.payload`); + } + if (kind === "payload") { + payloadNames.add(id.name); + } + return; + } + + if (id.type !== "ObjectPattern") { + return; + } + + const initializerKind = protectedPathKind(initializer, workflow); + for (const property of nodeList(id.properties)) { + if (property.type !== "Property") { + continue; + } + + const key = propertyName(property.key); + const value = isNode(property.value) ? property.value : undefined; + if (!isIdentifier(value)) { + continue; + } + + if (initializerKind === "event" && key === "payload") { + payloadNames.add(value.name); + continue; + } + + if (initializerKind === "payload") { + payloadNames.add(value.name); + continue; + } + + if (key === "event") { + const candidate = memberExpressionPath(initializer, "event"); + if (protectedPathKind(candidate, workflow) === "event") { + eventNames.add(value.name); + payloadNames.add(`${value.name}.payload`); + } + continue; + } + + if (key === "payload") { + const candidate = memberExpressionPath(initializer, "payload"); + if (protectedPathKind(candidate, workflow) === "payload") { + payloadNames.add(value.name); + } + } + } +} + +function protectedPathKind( + node: Node, + workflow: Pick, +): "event" | "payload" | undefined { + const base = memberBase(node); + if (!isIdentifier(base)) { + return; + } + const path = memberPath(node); + if (path.length === 0 || path[0] !== base.name) { + return; + } + + if (matchesProtectedPath(path, workflow.payloadNames)) { + return "payload"; + } + if (matchesProtectedPath(path, workflow.eventNames)) { + return "event"; + } +} + +function memberExpressionPath(object: Node, property: string): Node { + return { + type: "MemberExpression", + object, + property: { type: "Identifier", name: property }, + computed: false, + }; +} + +function nodeStart(node: Node): number | undefined { + const rangeStart = node.range?.[0]; + return typeof rangeStart === "number" ? rangeStart : undefined; +} + +function isWorkflowEntrypoint(node: Node | undefined, state: ProgramState): boolean { + if (isIdentifier(node)) { + return state.imports.workflowEntrypoint.has(node.name); + } + return isNamespaceMember(node, state.imports.cloudflareNamespaces, "WorkflowEntrypoint"); +} + +function isEffectFnCall(node: Node | undefined): boolean { + if (node?.type !== "CallExpression") { + return false; + } + const callee = unwrapExpression(node.callee); + return memberPath(callee).join(".") === "Effect.fn"; +} + +function isEffectPromiseCall(node: Node | undefined): boolean { + if (node?.type !== "CallExpression") { + return false; + } + const callee = unwrapExpression(node.callee); + const path = memberPath(callee).join("."); + return path === "Effect.promise" || path === "Effect.tryPromise"; +} + +function isOwnedEffectCall( + context: RuleContext, + effectCall: Node, + workflow: WorkflowContextInfo, +): boolean { + if (isOwnedAncestorExpression(context, effectCall, workflow)) { + return true; + } + + const ancestors = getAncestors(context, effectCall); + const parent = ancestors.at(-1); + const pipeCall = ancestors.at(-2); + if (parent?.type !== "MemberExpression" || pipeCall?.type !== "CallExpression") { + return false; + } + + if (propertyName(parent.property) !== "pipe") { + return false; + } + + return isOwnedAncestorExpression(context, pipeCall, workflow); +} + +function isReturnedFromEffectCallback(ancestors: Array, effectCall: Node): boolean { + const effectCallIndex = ancestors.indexOf(effectCall); + if (effectCallIndex < 0) { + return false; + } + + const callback = ancestors[effectCallIndex + 1]; + return isFunctionLike(callback); +} + +function isEnclosingFunctionOwned( + context: RuleContext, + expression: Node, + workflow: WorkflowContextInfo, +): boolean { + const ancestors = getAncestors(context, expression); + const fn = nearestFunction(ancestors); + if (!fn) { + return false; + } + if (fn === workflow.functionNode || isInsideStepDoCallback(context, expression, workflow)) { + return true; + } + + const functionIndex = ancestors.indexOf(fn); + const call = ancestors[functionIndex - 1]; + if (!isEagerArrayCallbackCall(call) || !nodeList(call.arguments).includes(fn)) { + return false; + } + + return isOwnedPromiseAggregateArgument(context, call, workflow); +} + +function containsStepCallInEagerCallback(call: Node, workflow: WorkflowContextInfo): boolean { + if (!isEagerArrayCallbackCall(call)) { + return false; + } + + return nodeList(call.arguments).some((argument) => { + if (!isFunctionLike(argument) || !isNode(argument.body)) { + return false; + } + return containsStepCall(argument.body, workflow); + }); +} + +function isEagerArrayCallbackCall(node: Node | undefined): boolean { + if (node?.type !== "CallExpression") { + return false; + } + const callee = unwrapExpression(node.callee); + if (callee?.type !== "MemberExpression") { + return false; + } + const method = propertyName(callee.property); + return method === "map" || method === "flatMap"; +} + +function isOwnedPromiseAggregateArgument( + context: RuleContext, + expression: Node, + workflow: WorkflowContextInfo, +): boolean { + const ancestors = getAncestors(context, expression); + const parent = ancestors.at(-1); + if (parent?.type !== "CallExpression" || !nodeList(parent.arguments).includes(expression)) { + return false; + } + const callee = unwrapExpression(parent.callee); + if (callee?.type !== "MemberExpression") { + return false; + } + const object = callee.object; + const method = propertyName(callee.property); + if ( + !isIdentifier(object) || + object.name !== "Promise" || + !method || + !["all", "allSettled", "race", "any"].includes(method) + ) { + return false; + } + return isOwnedAncestorExpression(context, parent, workflow); +} + +function isOwnedAncestorExpression( + context: RuleContext, + expression: Node, + workflow: WorkflowContextInfo, +): boolean { + const ancestors = getAncestors(context, expression); + const parent = ancestors.at(-1); + if (!parent) { + return false; + } + if (parent.type === "AwaitExpression" || parent.type === "YieldExpression") { + return true; + } + if (parent.type === "ReturnStatement") { + return nearestFunction(ancestors) === workflow.functionNode; + } + if (parent.type === "MemberExpression") { + const chainCall = ancestors.at(-2); + if (chainCall?.type !== "CallExpression") { + return false; + } + const method = propertyName(parent.property); + if (method === "then" || method === "catch" || method === "finally") { + return isOwnedAncestorExpression(context, chainCall, workflow); + } + } + if (parent.type === "ArrowFunctionExpression" && parent.body === expression) { + return ( + parent === workflow.functionNode || isInsideStepDoCallback(context, expression, workflow) + ); + } + return false; +} + +function protectedBindingKindAt( + context: RuleContext, + identifier: Node & { readonly name: string }, + workflow: WorkflowContextInfo, +): "event" | "payload" | undefined { + const bindingKind = bindingKindBefore(context, identifier, workflow); + if (bindingKind === "event" || bindingKind === "payload") { + return bindingKind; + } +} + +function isShadowedProtectedName( + context: RuleContext, + identifier: Node & { readonly name: string }, + workflow: WorkflowContextInfo, +): boolean { + if (!workflow.eventNames.has(identifier.name) && !workflow.payloadNames.has(identifier.name)) { + return false; + } + return bindingKindBefore(context, identifier, workflow) === "local"; +} + +function bindingKindBefore( + context: RuleContext, + identifier: Node & { readonly name: string }, + workflow: WorkflowContextInfo, +): "event" | "payload" | "local" | undefined { + const referenceStart = nodeStart(identifier); + if (referenceStart === undefined) { + return; + } + + let bestStart = -1; + let bestKind: "event" | "payload" | "local" | undefined; + collectBindingKinds( + workflow.functionNode, + identifier.name, + referenceStart, + workflow, + (start, kind) => { + if (start > bestStart) { + bestStart = start; + bestKind = kind; + } + }, + ); + return bestKind; +} + +function collectBindingKinds( + node: Node, + name: string, + referenceStart: number, + workflow: WorkflowContextInfo, + collect: (start: number, kind: "event" | "payload" | "local") => void, +): void { + for (const child of childNodes(node)) { + if ( + child !== node && + (isFunctionLike(child) || + child.type === "ClassDeclaration" || + child.type === "ClassExpression") + ) { + continue; + } + + if (child.type === "VariableDeclarator") { + const declarationStart = nodeStart(child); + const kind = variableBindingKind(child, name, workflow); + if (declarationStart !== undefined && declarationStart < referenceStart && kind) { + collect(declarationStart, kind); + } + } + + if (shouldDescendForBinding(child, referenceStart)) { + collectBindingKinds(child, name, referenceStart, workflow, collect); + } + } +} + +function shouldDescendForBinding(node: Node, referenceStart: number): boolean { + if (nodeContainsStart(node, referenceStart)) { + return true; + } + return [ + "ExportDefaultDeclaration", + "ExportNamedDeclaration", + "VariableDeclaration", + "VariableDeclarator", + ].includes(String(node.type)); +} + +function nodeContainsStart(node: Node, start: number): boolean { + const rangeStart = node.range?.[0]; + const rangeEnd = node.range?.[1]; + return ( + typeof rangeStart === "number" && + typeof rangeEnd === "number" && + rangeStart <= start && + start <= rangeEnd + ); +} + +function variableBindingKind( + declaration: Node, + name: string, + workflow: WorkflowContextInfo, +): "event" | "payload" | "local" | undefined { + const id = declaration.id; + const initializer = isNode(declaration.init) ? declaration.init : undefined; + if (!id) { + return; + } + + if (isIdentifier(id) && id.name === name) { + return initializer ? (protectedPathKind(initializer, workflow) ?? "local") : "local"; + } + + if (id.type !== "ObjectPattern") { + return; + } + + const initializerKind = initializer ? protectedPathKind(initializer, workflow) : undefined; + for (const property of nodeList(id.properties)) { + if (property.type !== "Property") { + continue; + } + const value = isNode(property.value) ? property.value : undefined; + if (!isIdentifier(value) || value.name !== name) { + continue; + } + + const key = propertyName(property.key); + if ((initializerKind === "event" && key === "payload") || initializerKind === "payload") { + return "payload"; + } + if ( + key === "event" && + initializer && + protectedPathKind(memberExpressionPath(initializer, "event"), workflow) === "event" + ) { + return "event"; + } + if ( + key === "payload" && + initializer && + protectedPathKind(memberExpressionPath(initializer, "payload"), workflow) === "payload" + ) { + return "payload"; + } + return "local"; + } +} + +function nearestFunction(ancestors: Array): Node | undefined { + return ancestors.findLast(isFunctionLike); +} + +function matchesProtectedPath(path: Array, protectedPaths: ReadonlySet): boolean { + for (let index = path.length; index >= 1; index -= 1) { + if (protectedPaths.has(path.slice(0, index).join("."))) { + return true; + } + } + return false; +} + +function isLocalSpecifier(module: string): boolean { + return module.startsWith("./") || module.startsWith("../"); +} + +function isWorkflowMakeCall(expression: Node | undefined, state: ProgramState): boolean { + if (!expression || expression.type !== "CallExpression") { + return false; + } + const callee = unwrapExpression(expression.callee); + if (callee?.type !== "MemberExpression" || propertyName(callee.property) !== "make") { + return false; + } + const object = unwrapExpression(callee.object); + if (isIdentifier(object)) { + return state.imports.workflow.has(object.name); + } + return isNamespaceMember(object, state.imports.sideffectNamespaces, "Workflow"); +} + +function isNamespaceMember(node: Node | undefined, namespaces: Set, name: string): boolean { + if (node?.type !== "MemberExpression") { + return false; + } + const object = node.object; + const property = node.property; + return ( + isIdentifier(object) && + namespaces.has(object.name) && + isIdentifier(property) && + property.name === name + ); +} + +function isFunctionLike(node: Node): boolean { + return ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes( + String(node.type), + ); +} + +const mutatingMethodNames = new Set([ + "add", + "clear", + "copyWithin", + "delete", + "fill", + "pop", + "push", + "reverse", + "set", + "shift", + "sort", + "splice", + "unshift", +]); + +function memberBase(node: Node): Node | undefined { + let current: Node | undefined = unwrapExpression(node); + while (current?.type === "MemberExpression") { + current = unwrapExpression(current.object); + } + return current; +} + +function memberPath(node: Node | undefined): Array { + const current = unwrapExpression(node); + if (!current) { + return []; + } + if (isIdentifier(current)) { + return [current.name]; + } + if (current.type !== "MemberExpression") { + return []; + } + const objectPath = memberPath(current.object); + const property = propertyName(current.property); + return property ? [...objectPath, property] : objectPath; +} + +function unwrapExpression(node: Node | undefined): Node | undefined { + let current = node; + while ( + current && + [ + "ChainExpression", + "TSAsExpression", + "TSSatisfiesExpression", + "TSNonNullExpression", + "TSTypeAssertion", + ].includes(String(current.type)) + ) { + current = current.expression; + } + return current; +} + +function propertyName(node: Node | undefined): string | undefined { + if (isIdentifier(node) || isPrivateIdentifier(node)) { + return node.name; + } + if (node?.type === "Literal" && typeof node.value === "string") { + return node.value; + } +} + +function nodeList(value: unknown): Array { + return Array.isArray(value) ? value.filter(isNode) : []; +} + +function isIdentifier(node: Node | undefined): node is Node & { readonly name: string } { + return node?.type === "Identifier" && typeof node.name === "string"; +} + +function isPrivateIdentifier(node: Node | undefined): node is Node & { readonly name: string } { + return node?.type === "PrivateIdentifier" && typeof node.name === "string"; +} + +function isTemplateValue( + value: unknown, +): value is { readonly cooked?: string; readonly raw?: string } { + return typeof value === "object" && value !== null; +} + +function childNodes(node: Node): Array { + const children: Array = []; + + for (const [key, value] of Object.entries(node)) { + if (key === "parent") { + continue; + } + if (Array.isArray(value)) { + children.push(...value.filter(isNode)); + continue; + } + if (isNode(value)) { + children.push(value); + } + } + + return children; +} + +function isNode(value: unknown): value is Node { + return ( + typeof value === "object" && + value !== null && + typeof (value as { type?: unknown }).type === "string" + ); +} diff --git a/packages/lint-plugin/src/shared/duration.ts b/packages/lint-plugin/src/shared/duration.ts new file mode 100644 index 0000000..a69c089 --- /dev/null +++ b/packages/lint-plugin/src/shared/duration.ts @@ -0,0 +1,45 @@ +const durationPattern = /^\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*$/; + +const unitToMilliseconds: Record = { + ms: 1, + millisecond: 1, + milliseconds: 1, + s: 1_000, + sec: 1_000, + second: 1_000, + seconds: 1_000, + m: 60_000, + min: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hr: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + d: 86_400_000, + day: 86_400_000, + days: 86_400_000, + w: 604_800_000, + week: 604_800_000, + weeks: 604_800_000, + month: 2_592_000_000, + months: 2_592_000_000, + y: 31_536_000_000, + year: 31_536_000_000, + years: 31_536_000_000, +}; + +export function parseDurationMilliseconds(value: string | number): number | undefined { + if (typeof value === "number") { + return value; + } + + const match = durationPattern.exec(value); + if (!match) { + return; + } + + const amount = Number(match[1]); + const unit = unitToMilliseconds[match[2]?.toLowerCase() ?? ""]; + return Number.isFinite(amount) && unit ? amount * unit : undefined; +} diff --git a/packages/lint-plugin/src/types.ts b/packages/lint-plugin/src/types.ts new file mode 100644 index 0000000..af0bd80 --- /dev/null +++ b/packages/lint-plugin/src/types.ts @@ -0,0 +1,61 @@ +export interface Node { + readonly type: string; + readonly [key: string]: unknown; + readonly argument?: Node; + readonly arguments?: ReadonlyArray; + readonly body?: Node | ReadonlyArray; + readonly callee?: Node; + readonly computed?: boolean; + readonly cooked?: string; + readonly declaration?: Node | null; + readonly declarations?: ReadonlyArray; + readonly expression?: Node; + readonly expressions?: ReadonlyArray; + readonly id?: Node; + readonly imported?: Node; + readonly init?: Node | null; + readonly key?: Node; + readonly left?: Node; + readonly local?: Node; + readonly name?: string; + readonly object?: Node; + readonly operator?: string; + readonly params?: ReadonlyArray; + readonly properties?: ReadonlyArray; + readonly property?: Node; + readonly quasis?: ReadonlyArray; + readonly range?: ReadonlyArray; + readonly raw?: string; + readonly source?: Node; + readonly specifiers?: ReadonlyArray; + readonly superClass?: Node | null; + readonly value?: unknown; +} + +export interface RuleContext { + readonly sourceCode: { + readonly getAncestors: (node: unknown) => Array; + }; + readonly report: (diagnostic: { readonly node: unknown; readonly messageId: string }) => void; +} + +export type WorkflowVisitor = Record void>; + +export interface WorkflowRule { + readonly meta: { + readonly type: "problem" | "suggestion" | "layout"; + readonly docs: { readonly description: string }; + readonly messages: Record; + readonly schema: Array; + }; + readonly create: (context: RuleContext) => WorkflowVisitor; + readonly createOnce: (context: RuleContext) => WorkflowVisitor; +} + +export interface WorkflowContextInfo { + readonly kind: "native" | "sideffect"; + readonly functionNode: Node; + readonly eventNames: ReadonlySet; + readonly payloadNames: ReadonlySet; + readonly stepName: string; +} diff --git a/packages/lint-plugin/tests/rules.test.ts b/packages/lint-plugin/tests/rules.test.ts new file mode 100644 index 0000000..ae65e01 --- /dev/null +++ b/packages/lint-plugin/tests/rules.test.ts @@ -0,0 +1,695 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { ESLint, RuleTester } from "eslint"; +import { expect, test } from "vite-plus/test"; + +import eslintPlugin from "../src/eslint.ts"; +import { awaitStepCallsRule } from "../src/rules/await-step-calls.ts"; +import { deterministicStepNamesRule } from "../src/rules/deterministic-step-names.ts"; +import { maxStepTimeout30mRule } from "../src/rules/max-step-timeout-30m.ts"; +import { noEventMutationRule } from "../src/rules/no-event-mutation.ts"; +import { noStepRaceAnyOutsideStepRule } from "../src/rules/no-step-race-any-outside-step.ts"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, +}); + +test("await-step-calls", () => { + tester.run("await-step-calls", awaitStepCallsRule as never, { + valid: [ + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + await step.do("fetch user", async () => event.payload.user); + return step.sleep("pause", "1 second"); + } + } + `, + ` + import { Workflow, Step } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer(async (workflow, step) => { + return await Promise.all([ + step.do(fetchUser, workflow.payload), + step.sleep("pause", "1 second"), + ]); + }); + `, + ` + import { Effect, Workflow, Step } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer( + Effect.fn(function* ({ payload }, step) { + return yield* Effect.promise(() => step.do(fetchUser, payload)); + }), + ); + `, + ` + import { Effect, Workflow, Step } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer( + Effect.fn(function* ({ payload }, step) { + return yield* Effect.tryPromise({ + try: () => step.do(fetchUser, payload), + catch: (error) => error, + }).pipe(Effect.catchAll((error) => Effect.fail(error))); + }), + ); + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch user", async () => event.payload.user).then((user) => user); + } + } + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return await Promise.allSettled([ + step.do("fetch user", async () => event.payload.user), + ]); + } + } + `, + ` + import { Effect, Workflow, Step } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer( + Effect.fn(function* ({ payload }, step) { + return yield* Effect.promise(() => { + return step.do(fetchUser, payload); + }); + }), + ); + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return Promise.all([1, 2].map(async (id) => { + return await step.do("fetch " + id, async () => event.payload.user); + })); + } + } + `, + ], + invalid: [ + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + step.do("fetch user", async () => event.payload.user); + } + } + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import { Workflow } from "sideffect"; + Workflow.make({ name: "users" }).toLayer(async (workflow, step) => { + const promise = step.sleep("pause", "1 second"); + return promise; + }); + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import { Workflow } from "sideffect"; + export const usersWorkflow = Workflow.make({ name: "users" }); + usersWorkflow.toLayer(async (workflow, step) => { + step.sleep("pause", "1 second"); + }); + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import { importedDefinitionWorkflow } from "./imported-definition"; + importedDefinitionWorkflow.toLayer(async (workflow, step) => { + step.sleep("pause", "1 second"); + }); + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import { Effect, Workflow, Step } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer( + Effect.fn(function* ({ payload }, step) { + step.do(fetchUser, payload); + }), + ); + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + [1].map(() => step.do("fetch user", async () => event.payload.user)); + } + } + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + [1].map(async () => await step.do("fetch user", async () => event.payload.user)); + } + } + `, + errors: [{ messageId: "danglingStepCall" }], + }, + { + code: ` + import * as cf from "cloudflare:workers"; + export class MyWorkflow extends cf.WorkflowEntrypoint { + async run(event, step) { + step.do("fetch user", async () => event.payload.user); + } + } + `, + errors: [{ messageId: "danglingStepCall" }], + }, + ], + }); +}); + +test("no-event-mutation", () => { + tester.run("no-event-mutation", noEventMutationRule as never, { + valid: [ + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + const nextPayload = { ...event.payload, user: "updated" }; + return step.do("save", async () => nextPayload); + } + } + `, + ` + class WorkflowEntrypoint {} + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + step.do("local", async () => event.payload); + } + } + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + let payload = event.payload; + payload = { user: "updated" }; + return step.do("save", async () => payload); + } + } + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + const payload = event.payload; + { + const payload = { items: [] }; + payload.items.push("local"); + } + return step.do("save", async () => payload); + } + } + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + const payload = event.payload; + { + const payload = { items: [] }; + Object.assign(payload, { user: "local" }); + } + return step.do("save", async () => payload); + } + } + `, + ], + invalid: [ + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + event.payload.user = "updated"; + return step.do("save", async () => event.payload); + } + } + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { Workflow } from "sideffect"; + Workflow.make({ name: "users" }).toLayer(async ({ payload }, step) => { + payload.user = "updated"; + return step.sleep("pause", "1 second"); + }); + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { Workflow } from "sideffect"; + Workflow.make({ name: "users" }).toLayer(async (workflow, step) => { + workflow.event.payload.user = "updated"; + return step.sleep("pause", "1 second"); + }); + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + const { payload } = event; + payload.user = "updated"; + return step.do("save", async () => event.payload); + } + } + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + event.payload.items.push("updated"); + return step.do("save", async () => event.payload); + } + } + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { Workflow } from "sideffect"; + Workflow.make({ name: "users" }).toLayer(async (workflow, step) => { + const payload = workflow.payload; + payload.items.splice(0, 1); + return step.sleep("pause", "1 second"); + }); + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + Object.assign(event.payload, { user: "updated" }); + return step.do("save", async () => event.payload); + } + } + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + const payload = event.payload; + Object.assign(payload, { user: "updated" }); + return step.do("save", async () => event.payload); + } + } + `, + errors: [{ messageId: "eventMutation" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + const payload = event.payload; + { + const payload = { items: [] }; + payload.items.push("local"); + } + payload.items.push("updated"); + return step.do("save", async () => event.payload); + } + } + `, + errors: [{ messageId: "eventMutation" }], + }, + ], + }); +}); + +test("no-step-race-any-outside-step", () => { + tester.run("no-step-race-any-outside-step", noStepRaceAnyOutsideStepRule as never, { + valid: [ + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("race", async () => Promise.race([ + step.do("slow", async () => "slow"), + step.do("fast", async () => "fast"), + ])); + } + } + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return Promise.race([ + () => step.do("slow", async () => "slow"), + Promise.resolve("fast"), + ]); + } + } + `, + ], + invalid: [ + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return Promise.race([ + step.do("slow", async () => "slow"), + step.do("fast", async () => "fast"), + ]); + } + } + `, + errors: [{ messageId: "stepRaceOutsideStep" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return Promise.race([1, 2].map(async (id) => { + return await step.do("fetch " + id, async () => id); + })); + } + } + `, + errors: [{ messageId: "stepRaceOutsideStep" }], + }, + ], + }); +}); + +test("eslint loads the public plugin export", async () => { + expect(eslintPlugin.configs.recommended).toBeDefined(); + expect(eslintPlugin.configs.strict).toBeDefined(); + + const eslint = new ESLint({ + overrideConfigFile: true, + overrideConfig: [ + { + plugins: { sideffect: eslintPlugin as never }, + rules: { "sideffect/await-step-calls": "error" }, + }, + ], + }); + + const [result] = await eslint.lintText(` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + step.do("fetch user", async () => event.payload.user); + } + } + `); + + expect(result?.messages.map((message) => message.ruleId)).toContain("sideffect/await-step-calls"); +}); + +test("deterministic-step-names", () => { + tester.run("deterministic-step-names", deterministicStepNamesRule as never, { + valid: [ + ` + import { Step, Workflow } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer(async (workflow, step) => { + return step.do(fetchUser, workflow.payload); + }); + `, + ], + invalid: [ + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do(\`run at \${Date.now()}\`, async () => event.payload); + } + } + `, + errors: [{ messageId: "nondeterministicStepName" }], + }, + { + code: ` + import { Step } from "sideffect"; + const bad = Step.make(\`fetch-\${Math.random()}\`, {}); + `, + errors: [{ messageId: "nondeterministicStepName" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do(Date(), async () => event.payload); + } + } + `, + errors: [{ messageId: "nondeterministicStepName" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do(globalThis.crypto.randomUUID(), async () => event.payload); + } + } + `, + errors: [{ messageId: "nondeterministicStepName" }], + }, + ], + }); +}); + +test("max-step-timeout-30m", () => { + tester.run("max-step-timeout-30m", maxStepTimeout30mRule as never, { + valid: [ + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch", { timeout: "30 minutes" }, async () => event.payload); + } + } + `, + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch", { timeout: 1800000 }, async () => event.payload); + } + } + `, + ], + invalid: [ + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch", { timeout: "2 hours" }, async () => event.payload); + } + } + `, + errors: [{ messageId: "timeoutTooLong" }], + }, + { + code: ` + import { Workflow, Step } from "sideffect"; + const fetchUser = Step.make("fetch user", {}); + Workflow.make({ name: "users" }).toLayer(async (workflow, step) => { + return step.do(fetchUser, workflow.payload, { timeout: "45 minutes" }); + }); + `, + errors: [{ messageId: "timeoutTooLong" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch", { timeout: 1800001 }, async () => event.payload); + } + } + `, + errors: [{ messageId: "timeoutTooLong" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch", { timeout: "1 day" }, async () => event.payload); + } + } + `, + errors: [{ messageId: "timeoutTooLong" }], + }, + { + code: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return step.do("fetch", { timeout: "1 week" }, async () => event.payload); + } + } + `, + errors: [{ messageId: "timeoutTooLong" }], + }, + ], + }); +}); + +test("oxlint loads the source plugin", () => { + const root = mkdtempSync(join(tmpdir(), "sideffect-lint-")); + try { + writeFileSync( + join(root, ".oxlintrc.json"), + JSON.stringify({ + jsPlugins: [ + { + name: "sideffect", + specifier: join(packageRoot, "src/oxlint.ts"), + }, + ], + rules: { + "sideffect/await-step-calls": "error", + }, + }), + ); + mkdirSync(join(root, "src")); + writeFileSync( + join(root, "src/workflow.js"), + ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + step.do("fetch user", async () => event.payload.user); + } + } + `, + ); + + let output: string | undefined; + try { + execFileSync( + join(packageRoot, "node_modules/oxlint/bin/oxlint"), + ["--config", ".oxlintrc.json", "src/workflow.js"], + { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }, + ); + } catch (error: unknown) { + output = + error instanceof Error && "stdout" in error && "stderr" in error + ? `${String(error.stdout)}\n${String(error.stderr)}` + : String(error); + } + expect(output).toContain("await-step-calls"); + expect(output).toContain("Workflow step call is not awaited"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("oxlint createOnce state is reset between files", () => { + const root = mkdtempSync(join(tmpdir(), "sideffect-lint-")); + try { + writeFileSync( + join(root, ".oxlintrc.json"), + JSON.stringify({ + jsPlugins: [ + { + name: "sideffect", + specifier: join(packageRoot, "src/oxlint.ts"), + }, + ], + rules: { + "sideffect/await-step-calls": "error", + }, + }), + ); + mkdirSync(join(root, "src")); + writeFileSync( + join(root, "src/a.js"), + ` + import { Workflow } from "sideffect"; + const externalWorkflow = Workflow.make({ name: "external" }); + externalWorkflow.toLayer(async (workflow, step) => { + return step.sleep("pause", "1 second"); + }); + `, + ); + writeFileSync( + join(root, "src/b.js"), + ` + externalWorkflow.toLayer(async (workflow, step) => { + step.sleep("pause", "1 second"); + }); + `, + ); + + const output = execFileSync( + join(packageRoot, "node_modules/oxlint/bin/oxlint"), + ["--config", ".oxlintrc.json", "src/a.js", "src/b.js"], + { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }, + ); + + expect(output).not.toContain("sideffect/await-step-calls"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/lint-plugin/tsconfig.json b/packages/lint-plugin/tsconfig.json new file mode 100644 index 0000000..8c90988 --- /dev/null +++ b/packages/lint-plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "moduleDetection": "force", + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/packages/lint-plugin/vite.config.ts b/packages/lint-plugin/vite.config.ts new file mode 100644 index 0000000..14fddf5 --- /dev/null +++ b/packages/lint-plugin/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite-plus"; + +const entry = [ + "src/index.ts", + "src/eslint.ts", + "src/oxlint.ts", + "src/rules/index.ts", + "src/rules/await-step-calls.ts", + "src/rules/deterministic-step-names.ts", + "src/rules/max-step-timeout-30m.ts", + "src/rules/no-event-mutation.ts", + "src/rules/no-step-race-any-outside-step.ts", +]; + +export default defineConfig({ + pack: { + entry, + exports: false, + }, + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +}); diff --git a/packages/sideffect/package.json b/packages/sideffect/package.json index 2293da9..8022874 100644 --- a/packages/sideffect/package.json +++ b/packages/sideffect/package.json @@ -30,8 +30,7 @@ "dev": "vp pack --watch", "test": "vp test", "check": "vp check", - "prepublishOnly": "vp run build", - "prepare": "vp config" + "prepublishOnly": "vp run build" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.40.0", diff --git a/vite.config.ts b/vite.config.ts index c7e1e8b..590c456 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,10 +11,18 @@ export default defineConfig({ ], }, lint: { + jsPlugins: [{ name: "sideffect", specifier: "./packages/lint-plugin/src/oxlint.ts" }], ignorePatterns: [ "workbench/cloudflare-workflows-tanstack/src/routeTree.gen.ts", "workbench/**/sideffect-env.d.ts", ], + rules: { + "sideffect/await-step-calls": "error", + "sideffect/no-event-mutation": "error", + "sideffect/no-step-race-any-outside-step": "warn", + "sideffect/deterministic-step-names": "warn", + "sideffect/max-step-timeout-30m": "error", + }, options: { typeAware: true, typeCheck: true }, }, run: { diff --git a/workbench/cloudflare-workflows-shared/tsconfig.json b/workbench/cloudflare-workflows-shared/tsconfig.json index bf8ca09..dfdde4e 100644 --- a/workbench/cloudflare-workflows-shared/tsconfig.json +++ b/workbench/cloudflare-workflows-shared/tsconfig.json @@ -5,6 +5,12 @@ "types": ["@cloudflare/workers-types"], "module": "es2022", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "paths": { + "sideffect": ["../../packages/sideffect/src/index.ts"], + "sideffect/cloudflare": ["../../packages/sideffect/src/cloudflare.ts"], + "sideffect/vite": ["../../packages/sideffect/src/vite.ts"] + }, "strict": true, "skipLibCheck": true, "isolatedModules": true,