From faccc180e49ada6b6cf0cab401d58eda42838c18 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 29 Sep 2026 17:44:51 -0700 Subject: [PATCH 01/13] chore(lint): adopt anti-slop, automation, and typed lint rules - Upgrade oxlint/@oxlint/plugins 1.86.0, oxlint-tsgolint 7.0.2003, @effect/tsgo 0.47.0; patch oxlint in `prepare` and extend the @effect/tsgo recommended preset. - Enable type-aware built-ins plus complexity (20), no-eq-null, no-empty-function, no-misused-spread, and fix the code they flagged. - Vendor the full kyle-anti-slop plugin (generic + Effect), the upstream automation rules, the import-extensions plugin, and the typed rule engine; enable every rule the codebase already passes. - Run the typed rules as part of `bun run lint`; replace array `.length` emptiness checks with Effect Array predicates. - Move fold-codex tests and example that need fold-agent into fold-agent, removing the reverse dependency. - Keep TUI code exempt from rules it violates. Rules still off are listed in tools/oxlint/README.md. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0ef65-e340-707e-9917-609260f74450 --- .oxlintrc.jsonc | 52 +- AGENTS.md | 6 +- bun.lock | 122 ++-- package.json | 16 +- .../examples/CodexAgent.ts | 6 +- packages/fold-agent/package.json | 1 + .../scripts/update-managed-binaries.ts | 4 +- .../scripts/update-model-catalog.ts | 4 +- .../fold-agent/src/Bin/ManagedBinaries.ts | 4 +- .../fold-agent/src/Catalog/LoadCatalog.ts | 4 +- packages/fold-agent/src/Catalog/Normalize.ts | 3 +- .../src/Compatibility/CodexInstructions.ts | 4 +- .../src/Compatibility/CodexPlugins.ts | 6 +- .../src/Compatibility/GrokInstructions.ts | 42 +- .../src/Compatibility/GrokPlugins.ts | 128 ++-- .../fold-agent/src/Config/ConfigSchema.ts | 4 +- .../fold-agent/src/Config/ConfigSchemaJson.ts | 4 +- .../fold-agent/src/Config/ProviderConfig.ts | 45 +- packages/fold-agent/src/Memory/AgentFiles.ts | 4 +- packages/fold-agent/src/Mode/Launch.ts | 4 +- packages/fold-agent/src/Tools/BashTool.ts | 23 +- .../Codex}/CodexImageReadDelivery.vi.test.ts | 4 +- .../test/Codex}/CodexLive.vi.test.ts | 2 +- .../EffectToolResultEncodingRepro.vi.test.ts | 0 .../Codex}/SessionModelPathTestHarness.ts | 3 +- ...ToolResultFormattingIntegration.vi.test.ts | 2 +- .../test/Config/ConfigProfiles.vi.test.ts | 28 +- .../test/Tools/WebFetchTool.vi.test.ts | 5 +- packages/fold-cli/src/Commands.ts | 8 +- packages/fold-cli/src/Renderer.ts | 4 +- packages/fold-cli/src/Run.ts | 6 +- .../fixtures/TuiProviderManagementFixture.tsx | 9 +- packages/fold-codex/package.json | 1 - packages/fold-codex/src/CodexModel.ts | 9 +- .../src/AgentRuntime/AgentRuntimeLayer.ts | 78 ++- packages/fold-core/src/Api/Provisioning.ts | 4 +- packages/fold-core/src/Api/StartSession.ts | 3 +- .../src/Compaction/CompactionEngine.ts | 8 +- .../src/Compaction/CompactionLayer.ts | 11 +- .../fold-core/src/EventLog/EventLogService.ts | 4 +- .../src/HookRunner/HookRunnerLayer.ts | 2 +- packages/fold-core/src/HookRunner/Types.ts | 2 +- .../fold-core/src/Model/RequestBuilder.ts | 34 +- .../fold-core/src/Projection/Projection.ts | 13 +- .../fold-core/src/Session/SessionControls.ts | 6 +- packages/fold-core/src/Skills/SkillTool.ts | 23 +- .../src/StopConditions/StopConditions.ts | 4 +- .../fold-core/src/Subagents/AgentIdRef.ts | 4 +- .../fold-core/src/Subagents/SubagentTool.ts | 8 +- .../fold-core/src/Subagents/SubagentsLayer.ts | 5 +- .../src/ToolRuntime/ToolContextServices.ts | 2 +- .../src/ToolRuntime/ToolRuntimeService.ts | 4 +- packages/fold-core/src/Tools/EditEngine.ts | 4 +- packages/fold-core/src/Tools/PatchEngine.ts | 186 +++--- packages/fold-core/src/Tools/Truncation.ts | 7 +- .../AgentRuntimePrefix.vi.test.ts | 4 +- .../test/TestLayers/TestStopController.ts | 4 +- tools/oxlint/README.md | 64 +- tools/oxlint/anti-slop/effect/index.ts | 21 + .../rules/no-manual-effect-error-tag.ts | 44 ++ .../effect/rules/no-manual-tag-comparison.ts | 34 + .../rules/no-manual-tagged-construction.ts | 47 ++ .../rules/no-service-constructor-imports.ts | 50 ++ .../effect/rules/prefer-effect-match.ts | 48 ++ .../anti-slop/effect/shared/tagged-values.ts | 54 ++ tools/oxlint/anti-slop/index.ts | 37 +- .../anti-slop/rules/max-ternary-depth.ts | 80 +++ .../rules/no-chained-type-assertions.ts | 76 +++ tools/oxlint/anti-slop/rules/no-comments.ts | 25 + .../no-conditional-empty-array-spread.ts | 44 ++ .../anti-slop/rules/no-conditional-spread.ts | 42 ++ .../rules/no-known-value-widening.ts | 217 ++++++ .../anti-slop/rules/no-module-mocking.ts | 1 + .../anti-slop/rules/no-object-parameters.ts | 16 +- .../anti-slop/rules/no-reflect-apply.ts | 1 + .../oxlint/anti-slop/rules/no-reflect-get.ts | 27 + .../rules/no-reprovide-ambient-service.ts | 123 ++++ .../anti-slop/rules/no-runtime-typeof.ts | 63 ++ .../rules/no-shape-in-symbol-names.ts | 39 ++ .../anti-slop/rules/no-unknown-parameters.ts | 81 +++ .../anti-slop/rules/no-unknown-returns.ts | 105 +++ .../rules/no-unknown-type-aliases.ts | 62 ++ .../rules/no-unsafe-dictionary-type.ts | 124 ++++ .../anti-slop/rules/no-widen-then-assert.ts | 343 ++++++++++ ...quire-safety-comment-for-type-assertion.ts | 60 ++ .../anti-slop/shared/dictionary-types.ts | 443 ++++++++++++ .../shared/lexical-type-parameters.ts | 3 +- .../oxlint/anti-slop/shared/reflect-method.ts | 1 + tools/oxlint/automation/catalog.ts | 134 ++++ tools/oxlint/automation/index.ts | 87 +++ tools/oxlint/automation/profiles.ts | 21 + .../rules/no-api-backend-imports.ts | 107 +++ .../rules/no-api-repository-imports.ts | 75 +++ .../rules/no-banned-type-assertions.ts | 38 ++ .../rules/no-cascading-layer-provide.ts | 98 +++ tools/oxlint/automation/rules/no-comments.ts | 40 ++ .../oxlint/automation/rules/no-css-modules.ts | 65 ++ .../rules/no-direct-browser-storage.ts | 100 +++ .../automation/rules/no-direct-fetch.ts | 44 ++ .../rules/no-direct-xstate-create-machine.ts | 58 ++ .../rules/no-direct-xstate-use-selector.ts | 84 +++ .../automation/rules/no-effect-asvoid.ts | 33 + .../rules/no-fixed-height-on-content.ts | 286 ++++++++ .../oxlint/automation/rules/no-global-json.ts | 67 ++ .../oxlint/automation/rules/no-in-operator.ts | 30 + tools/oxlint/automation/rules/no-jsx-style.ts | 51 ++ .../rules/no-multiple-function-params.ts | 126 ++++ .../rules/no-multiple-xstate-hooks.ts | 136 ++++ .../rules/no-nested-effect-array-methods.ts | 117 ++++ .../rules/no-nested-layer-provide.ts | 53 ++ .../rules/no-non-route-layout-files.ts | 60 ++ .../rules/no-optional-function-parameters.ts | 67 ++ .../no-react-component-inner-functions.ts | 266 ++++++++ ...no-react-non-component-function-exports.ts | 575 ++++++++++++++++ .../automation/rules/no-react-state-hooks.ts | 62 ++ .../rules/no-reexport-only-modules.ts | 72 ++ .../rules/no-single-use-private-functions.ts | 359 ++++++++++ .../rules/no-single-use-xstate-actions.ts | 105 +++ .../rules/no-single-use-xstate-guards.ts | 79 +++ .../automation/rules/no-sql-type-parameter.ts | 36 + .../no-static-effect-service-forwarders.ts | 297 +++++++++ tools/oxlint/automation/rules/no-switch.ts | 26 + tools/oxlint/automation/rules/no-try-catch.ts | 26 + .../automation/rules/no-type-assertion.ts | 92 +++ .../automation/rules/no-typeof-object.ts | 43 ++ .../automation/rules/pipe-max-arguments.ts | 33 + .../rules/private-function-prefix.ts | 58 ++ .../rules/require-approved-ui-primitives.ts | 86 +++ .../require-cn-for-classname-composition.ts | 375 +++++++++++ .../require-context-service-in-services.ts | 142 ++++ .../rules/require-default-component-export.ts | 288 ++++++++ .../rules/require-tsx-in-ui-folders.ts | 74 +++ .../rules/require-xstate-event-satisfies.ts | 159 +++++ .../automation/rules/xstate-single-use.ts | 184 +++++ tools/oxlint/import-extensions.mjs | 50 ++ tools/typed/README.md | 23 + tools/typed/src/cli.ts | 120 ++++ tools/typed/src/config.ts | 203 ++++++ tools/typed/src/engine.ts | 191 ++++++ tools/typed/src/index.ts | 12 + tools/typed/src/registry.ts | 19 + tools/typed/src/rule.ts | 39 ++ .../no-xstate-derived-boolean-context.ts | 628 ++++++++++++++++++ .../src/rules/prefer-effect-array-match.ts | 116 ++++ .../src/rules/prefer-typed-schema-apis.ts | 188 ++++++ .../typed/src/workspace-rules/no-svg-files.ts | 70 ++ typed-lint.config.ts | 30 + 147 files changed, 10075 insertions(+), 390 deletions(-) rename packages/{fold-codex => fold-agent}/examples/CodexAgent.ts (93%) rename packages/{fold-codex/test => fold-agent/test/Codex}/CodexImageReadDelivery.vi.test.ts (97%) rename packages/{fold-codex/test => fold-agent/test/Codex}/CodexLive.vi.test.ts (98%) rename packages/{fold-codex/test => fold-agent/test/Codex}/EffectToolResultEncodingRepro.vi.test.ts (100%) rename packages/{fold-codex/test => fold-agent/test/Codex}/SessionModelPathTestHarness.ts (99%) rename packages/{fold-codex/test => fold-agent/test/Codex}/ToolResultFormattingIntegration.vi.test.ts (98%) create mode 100644 tools/oxlint/anti-slop/effect/index.ts create mode 100644 tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts create mode 100644 tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts create mode 100644 tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts create mode 100644 tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts create mode 100644 tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts create mode 100644 tools/oxlint/anti-slop/effect/shared/tagged-values.ts create mode 100644 tools/oxlint/anti-slop/rules/max-ternary-depth.ts create mode 100644 tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts create mode 100644 tools/oxlint/anti-slop/rules/no-comments.ts create mode 100644 tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts create mode 100644 tools/oxlint/anti-slop/rules/no-conditional-spread.ts create mode 100644 tools/oxlint/anti-slop/rules/no-known-value-widening.ts create mode 100644 tools/oxlint/anti-slop/rules/no-reflect-get.ts create mode 100644 tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts create mode 100644 tools/oxlint/anti-slop/rules/no-runtime-typeof.ts create mode 100644 tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-parameters.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-returns.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts create mode 100644 tools/oxlint/anti-slop/rules/no-widen-then-assert.ts create mode 100644 tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts create mode 100644 tools/oxlint/anti-slop/shared/dictionary-types.ts create mode 100644 tools/oxlint/automation/catalog.ts create mode 100644 tools/oxlint/automation/profiles.ts create mode 100644 tools/oxlint/automation/rules/no-api-backend-imports.ts create mode 100644 tools/oxlint/automation/rules/no-api-repository-imports.ts create mode 100644 tools/oxlint/automation/rules/no-banned-type-assertions.ts create mode 100644 tools/oxlint/automation/rules/no-cascading-layer-provide.ts create mode 100644 tools/oxlint/automation/rules/no-comments.ts create mode 100644 tools/oxlint/automation/rules/no-css-modules.ts create mode 100644 tools/oxlint/automation/rules/no-direct-browser-storage.ts create mode 100644 tools/oxlint/automation/rules/no-direct-fetch.ts create mode 100644 tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts create mode 100644 tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts create mode 100644 tools/oxlint/automation/rules/no-effect-asvoid.ts create mode 100644 tools/oxlint/automation/rules/no-fixed-height-on-content.ts create mode 100644 tools/oxlint/automation/rules/no-global-json.ts create mode 100644 tools/oxlint/automation/rules/no-in-operator.ts create mode 100644 tools/oxlint/automation/rules/no-jsx-style.ts create mode 100644 tools/oxlint/automation/rules/no-multiple-function-params.ts create mode 100644 tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts create mode 100644 tools/oxlint/automation/rules/no-nested-effect-array-methods.ts create mode 100644 tools/oxlint/automation/rules/no-nested-layer-provide.ts create mode 100644 tools/oxlint/automation/rules/no-non-route-layout-files.ts create mode 100644 tools/oxlint/automation/rules/no-optional-function-parameters.ts create mode 100644 tools/oxlint/automation/rules/no-react-component-inner-functions.ts create mode 100644 tools/oxlint/automation/rules/no-react-non-component-function-exports.ts create mode 100644 tools/oxlint/automation/rules/no-react-state-hooks.ts create mode 100644 tools/oxlint/automation/rules/no-reexport-only-modules.ts create mode 100644 tools/oxlint/automation/rules/no-single-use-private-functions.ts create mode 100644 tools/oxlint/automation/rules/no-single-use-xstate-actions.ts create mode 100644 tools/oxlint/automation/rules/no-single-use-xstate-guards.ts create mode 100644 tools/oxlint/automation/rules/no-sql-type-parameter.ts create mode 100644 tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts create mode 100644 tools/oxlint/automation/rules/no-switch.ts create mode 100644 tools/oxlint/automation/rules/no-try-catch.ts create mode 100644 tools/oxlint/automation/rules/no-type-assertion.ts create mode 100644 tools/oxlint/automation/rules/no-typeof-object.ts create mode 100644 tools/oxlint/automation/rules/pipe-max-arguments.ts create mode 100644 tools/oxlint/automation/rules/private-function-prefix.ts create mode 100644 tools/oxlint/automation/rules/require-approved-ui-primitives.ts create mode 100644 tools/oxlint/automation/rules/require-cn-for-classname-composition.ts create mode 100644 tools/oxlint/automation/rules/require-context-service-in-services.ts create mode 100644 tools/oxlint/automation/rules/require-default-component-export.ts create mode 100644 tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts create mode 100644 tools/oxlint/automation/rules/require-xstate-event-satisfies.ts create mode 100644 tools/oxlint/automation/rules/xstate-single-use.ts create mode 100644 tools/oxlint/import-extensions.mjs create mode 100644 tools/typed/README.md create mode 100644 tools/typed/src/cli.ts create mode 100644 tools/typed/src/config.ts create mode 100644 tools/typed/src/engine.ts create mode 100644 tools/typed/src/index.ts create mode 100644 tools/typed/src/registry.ts create mode 100644 tools/typed/src/rule.ts create mode 100644 tools/typed/src/rules/no-xstate-derived-boolean-context.ts create mode 100644 tools/typed/src/rules/prefer-effect-array-match.ts create mode 100644 tools/typed/src/rules/prefer-typed-schema-apis.ts create mode 100644 tools/typed/src/workspace-rules/no-svg-files.ts create mode 100644 typed-lint.config.ts diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 83d01b2..1ce0e9e 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -1,13 +1,30 @@ { - "$schema": "./node_modules/oxlint/configuration_schema.json", + "$schema": "./node_modules/@effect/tsgo/oxlint-schema.json", + // Effect-aware type-aware rules from the patched oxlint-tsgolint (`effect-tsgo patch --oxlint`). + "extends": ["./node_modules/@effect/tsgo/oxlint-presets/recommended.json"], // The `correctness` category is enabled by default. Keep the broader Effect Channels // plugin set and Vitest's test-specific rules. - "plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node", "vitest"], + "plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node", "vitest", "effecttsgo"], "jsPlugins": [ { "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }, + { "name": "anti-slop-effect", "specifier": "./tools/oxlint/anti-slop/effect/index.ts" }, { "name": "automation", "specifier": "./tools/oxlint/automation/index.ts" }, + { "name": "import-extensions", "specifier": "./tools/oxlint/import-extensions.mjs" }, + ], + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + ".release/**", + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".cursor/**", + ".humanlayer/**", + ".opencode/**", + ".pi/**", + "tools/**", ], - "ignorePatterns": ["**/node_modules/**", "**/dist/**", ".release/**", "tools/oxlint/**"], "rules": { "anti-slop/no-conditional-empty-object-spread": "error", "anti-slop/no-module-mocking": "error", @@ -23,6 +40,19 @@ "automation/prefer-tagged-error-handling": "error", "automation/no-ambient-nondeterminism": "error", "anti-slop/no-reflect-apply": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop-effect/no-manual-effect-error-tag": "error", + "anti-slop-effect/no-manual-tag-comparison": "error", + "anti-slop-effect/prefer-effect-match": "error", + "automation/no-cascading-layer-provide": "error", + "automation/no-nested-effect-array-methods": "error", + "automation/no-nested-layer-provide": "error", + "automation/no-sql-type-parameter": "error", + "automation/no-static-effect-service-forwarders": "error", + "automation/pipe-max-arguments": "error", + "automation/require-context-service-in-services": "error", + "import-extensions/no-relative-source-extensions": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], "typescript/no-import-type-side-effects": "error", "import/no-duplicates": "error", @@ -31,6 +61,18 @@ "typescript/no-unnecessary-type-assertion": "error", "typescript/no-unnecessary-type-constraint": "error", "typescript/no-useless-empty-export": "error", + "typescript/await-thenable": "error", + "typescript/no-base-to-string": "error", + "typescript/no-floating-promises": "error", + "typescript/no-meaningless-void-operator": "error", + "typescript/no-misused-promises": "error", + "typescript/no-misused-spread": "error", + "typescript/no-redundant-type-constituents": "error", + "typescript/restrict-template-expressions": "error", + "typescript/unbound-method": "error", + "eslint/complexity": ["error", { "max": 20 }], + "eslint/no-empty-function": "error", + "eslint/no-eq-null": "error", "eslint/no-console": "error", "eslint/no-var": "error", "eslint/no-useless-constructor": "error", @@ -76,6 +118,9 @@ "anti-slop/no-module-mocking": "off", "anti-slop/no-object-parameters": "off", "anti-slop/no-reflect-apply": "off", + "anti-slop-effect/no-manual-tag-comparison": "off", + "anti-slop-effect/prefer-effect-match": "off", + "eslint/complexity": "off", "automation/no-disable-validation": "off", "automation/no-shadowed-standard-array-static": "off", "automation/no-silent-error-swallow": "off", @@ -126,6 +171,7 @@ "files": ["packages/vitest-config/**/*.ts"], "rules": { "import/extensions": "off", + "import-extensions/no-relative-source-extensions": "off", }, }, { diff --git a/AGENTS.md b/AGENTS.md index e8833b2..b15c0c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,15 +8,15 @@ Bun monorepo. Packages live in `packages/*` and run TypeScript directly (no buil - **Framework:** Effect **v4** (`4.0.0-rc.109`) - **Testing:** Vitest + `@effect/vitest` - **Lint / format:** oxlint + oxfmt (120 col, no semicolons) -- **Effect editor tooling:** `@effect/tsgo` with the `@effect/language-service` TS plugin. `tsc` is patched for - build-time Effect diagnostics via the `prepare` script (`effect-tsgo patch --typescript`). +- **Effect editor tooling:** `@effect/tsgo` with the `@effect/language-service` TS plugin. `tsc` and oxlint are patched + for Effect diagnostics via the `prepare` script (`effect-tsgo patch --typescript --oxlint --force`). ## Commands - `bun install` — install deps (also patches TypeScript for Effect diagnostics) - `bun run test` / `bun run test:watch` — run tests - `bun run typecheck` — typecheck every package -- `bun run lint` / `bun run lint:fix` — oxlint +- `bun run lint` / `bun run lint:fix` — oxlint (with the `@effect/tsgo` recommended preset), then the typed rules in `tools/typed`; see `tools/oxlint/README.md` - `bun run format` / `bun run format:check` — oxfmt ## Dependency versions diff --git a/bun.lock b/bun.lock index 42bc127..f98babb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,17 @@ "": { "name": "fold", "devDependencies": { - "@effect/tsgo": "0.36.4", - "@oxlint/plugins": "1.77.0", + "@effect/tsgo": "0.47.0", + "@oxlint/plugins": "1.86.0", "@types/bun": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", + "fast-glob": "3.3.3", "oxfmt": "0.62.0", - "oxlint": "1.77.0", - "oxlint-tsgolint": "0.24.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", "typescript": "catalog:", + "typescript-compiler": "npm:typescript@6.0.2", "vitest": "catalog:", }, }, @@ -92,6 +94,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@humanlayer/effect-ai-openai": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "@types/turndown": "catalog:", "effect": "catalog:", @@ -146,7 +149,6 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@humanlayer/fold-agent": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "effect": "catalog:", "typescript": "catalog:", @@ -327,21 +329,21 @@ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], - "@effect/tsgo": ["@effect/tsgo@0.36.4", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.36.4", "@effect/tsgo-darwin-x64": "0.36.4", "@effect/tsgo-linux-arm": "0.36.4", "@effect/tsgo-linux-arm64": "0.36.4", "@effect/tsgo-linux-x64": "0.36.4", "@effect/tsgo-win32-arm64": "0.36.4", "@effect/tsgo-win32-x64": "0.36.4" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-fNmdUV6FgXnvIcG18AVS1SRXt7z9jsyBh5CKuJYmTsE+DgbLaWF0CevKHx7hQPcidDWRVmdJYfU66Jvc/ZEt6w=="], + "@effect/tsgo": ["@effect/tsgo@0.47.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.47.0", "@effect/tsgo-darwin-x64": "0.47.0", "@effect/tsgo-linux-arm": "0.47.0", "@effect/tsgo-linux-arm64": "0.47.0", "@effect/tsgo-linux-x64": "0.47.0", "@effect/tsgo-win32-arm64": "0.47.0", "@effect/tsgo-win32-x64": "0.47.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-70L1cp9Lj8rJeKmLseg2RLaQ95IM2CR3BFF6Ytq1fjio3V9cEwMuOzBYSRlIAK2x+dTyxvFdabvbMr+DF7XD7g=="], - "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.36.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oOcWdKQucNch6G4CvidHEzmfgeKEPMvFcz+DXuKGonBLwVHX+i1VYGbZwccEOoiitOg7eWVl4e8S0qKMXg3/gg=="], + "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-X4MF7+GOiOk0EzcdY2bqSFl8yXYoD3VvzFcDE5ZyEO1oLGa4UGJjRmJBQt/LQFil8MW68uvAmE+Thh/Y2VTyuA=="], - "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.36.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4QZh0n/nUoTI7sp4o7gDEviJ/jXH+IFTVqIyQ2w2fqqPoOvaGl9bVoR/2f9j/67hvY4DHcXKaccpmyoRYhaYsw=="], + "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vANaJYOFsIpX5KiMI0p3Y1DeFIT6N1Y8uTeBo6WKOCfeI1r0vcMUX1gEiLDPJBdweH7bbvyzbiUFX0/fnWgKEg=="], - "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.36.4", "", { "os": "linux", "cpu": "arm" }, "sha512-10oTUnOK/RCfmTMFvMndNIfk61uviyYBRpbxhDd/SiU6DYCmWlnZ/xhGmgyj5uiysOiFNsp3NKxT0d9y4uAz8g=="], + "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-nmoRTsK8nfcPl2uhksmxpyXFoKcAHkZPrUiSheO8wEbdCo/aFTe2UKfAgrRtMZ8qr3gD1tpKxXWrRfvlZTwp0g=="], - "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.36.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOv+wVbZWgBt9PGXPA874qgw151GrzkWqaswe+4TXjhdw/M4i35PCUH5T+uaQj3FnQ1NWiPqHIGfNBRjf3xG6w=="], + "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-44tFG3S9MU6vQCsDIQlV/72NHrAmo4T9Q6adFJDNjaRzv4eIEu9TPHAflIVghCRFWkmvhElnKp6VSh4vJ5jwlw=="], - "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.36.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+BuwiG5d8dLNrrN1PB5GA3gRkBwvlUYmMkm/XkttaLnppp9jePFkB8CgAiNV8j290q7BVO2nQjao5aPkVwmluw=="], + "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DL3DiNZNMKnEdVNHpQPtuDSV4DdAtY6owgfQYYgF1uExjqS/5ZWZICEm1ZpdeLMIaCFbRdeSv7uEu1RrTGCuyw=="], - "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.36.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-HsW90wbMWOeorUeNMdTqTcMZIDcCXdkeFGXTUkx9K1R21fXSw+QEdPtTmEyKsRMXEH4vqek/HQa0yMkUT0CJzA=="], + "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-LctXaODCUDIjmxcSqqQ2OnLffQwLIct09YsAs9ZiTBPnxdRy4eQYPxe4ImfvTIEDkYDG/nfG3h7tL+EIvix45A=="], - "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.36.4", "", { "os": "win32", "cpu": "x64" }, "sha512-q4j1q0ESYxxookjA0ECz/84DEH2MsP51K0aZb3uXKfxagHDASPoWDcVu4BJTcwrK5kdZzvw9QrCPjAXe9WZqng=="], + "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-keYrly656yhEdB3pigkCJaT2oI7I7RcB1QcF4akTggBNuLsh1WT5Ksy2xuHD8QkUiNz6pRwFUSW9Qh83zJQX8g=="], "@effect/vitest": ["@effect/vitest@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112", "vitest": ">=4.1.0 <5.0.0" } }, "sha512-mEKh/FI64mt8JK1/v9mpOrJYdnp+UFZdRUBMEdZMiKz7klg6NPqVgg/oeAGH6wOOQc2iAPcfc2H9BbAv1KyzMQ=="], @@ -413,6 +415,12 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], @@ -477,57 +485,57 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2003", "", { "os": "darwin", "cpu": "x64" }, "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2003", "", { "os": "linux", "cpu": "arm64" }, "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2003", "", { "os": "linux", "cpu": "x64" }, "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2003", "", { "os": "win32", "cpu": "arm64" }, "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2003", "", { "os": "win32", "cpu": "x64" }, "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.77.0", "", { "os": "android", "cpu": "arm" }, "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.86.0", "", { "os": "android", "cpu": "arm" }, "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.77.0", "", { "os": "android", "cpu": "arm64" }, "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.86.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.77.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.77.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.77.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.86.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.77.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.86.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.77.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.86.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.77.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.86.0", "", { "os": "none", "cpu": "arm64" }, "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.77.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.77.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.86.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.77.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ=="], - "@oxlint/plugins": ["@oxlint/plugins@1.77.0", "", {}, "sha512-6KtPXskPgpt+0Kyl2nNSxNnYXt7lbkxW1C7vi2L7xwtq/AisZlsmV+hnzOTEyM5tU1TxPv1GpTqpUBbZfPzBLg=="], + "@oxlint/plugins": ["@oxlint/plugins@1.86.0", "", {}, "sha512-JeAiaaRMyUiUEQHQ7zBsJn1aEdbLyt1HJsOs4XKIIlATNxcvWRrRxneZA+RF4G03cQobq3IhTqSfyLhOFv03qA=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], @@ -671,6 +679,8 @@ "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], @@ -715,8 +725,14 @@ "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "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=="], + + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], @@ -733,6 +749,8 @@ "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], @@ -743,6 +761,12 @@ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], @@ -791,6 +815,10 @@ "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], @@ -813,9 +841,9 @@ "oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="], - "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], + "oxlint": ["oxlint@1.86.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.86.0", "@oxlint/binding-android-arm64": "1.86.0", "@oxlint/binding-darwin-arm64": "1.86.0", "@oxlint/binding-darwin-x64": "1.86.0", "@oxlint/binding-freebsd-x64": "1.86.0", "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", "@oxlint/binding-linux-arm-musleabihf": "1.86.0", "@oxlint/binding-linux-arm64-gnu": "1.86.0", "@oxlint/binding-linux-arm64-musl": "1.86.0", "@oxlint/binding-linux-ppc64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-musl": "1.86.0", "@oxlint/binding-linux-s390x-gnu": "1.86.0", "@oxlint/binding-linux-x64-gnu": "1.86.0", "@oxlint/binding-linux-x64-musl": "1.86.0", "@oxlint/binding-openharmony-arm64": "1.86.0", "@oxlint/binding-win32-arm64-msvc": "1.86.0", "@oxlint/binding-win32-ia32-msvc": "1.86.0", "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg=="], - "oxlint-tsgolint": ["oxlint-tsgolint@0.24.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.24.0", "@oxlint-tsgolint/darwin-x64": "0.24.0", "@oxlint-tsgolint/linux-arm64": "0.24.0", "@oxlint-tsgolint/linux-x64": "0.24.0", "@oxlint-tsgolint/win32-arm64": "0.24.0", "@oxlint-tsgolint/win32-x64": "0.24.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw=="], + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -843,6 +871,8 @@ "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -855,8 +885,12 @@ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -897,12 +931,16 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "typescript-compiler": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "undici": ["undici@8.10.1", "", {}, "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -929,6 +967,8 @@ "make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/package.json b/package.json index b432a5f..1842384 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "typecheck": "bun run build:providers && bun run --parallel --if-present --filter './packages/*' typecheck", "test": "bun run --parallel --if-present --filter './packages/*' test", "test:watch": "bun run --parallel --if-present --filter './packages/*' test:watch", - "lint": "oxlint --type-aware", - "lint:fix": "oxlint --type-aware --fix", + "lint": "oxlint --type-aware && bun tools/typed/src/cli.ts check --config typed-lint.config.ts", + "lint:fix": "oxlint --type-aware --fix && bun tools/typed/src/cli.ts check --config typed-lint.config.ts", "format": "oxfmt", "format:check": "oxfmt --check", - "prepare": "effect-tsgo patch --typescript" + "prepare": "effect-tsgo patch --typescript --oxlint --force" }, "workspaces": { "packages": [ @@ -48,15 +48,17 @@ } }, "devDependencies": { - "@effect/tsgo": "0.36.4", - "@oxlint/plugins": "1.77.0", + "@effect/tsgo": "0.47.0", + "@oxlint/plugins": "1.86.0", "@types/bun": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", + "fast-glob": "3.3.3", "oxfmt": "0.62.0", - "oxlint": "1.77.0", - "oxlint-tsgolint": "0.24.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", "typescript": "catalog:", + "typescript-compiler": "npm:typescript@6.0.2", "vitest": "catalog:" } } diff --git a/packages/fold-codex/examples/CodexAgent.ts b/packages/fold-agent/examples/CodexAgent.ts similarity index 93% rename from packages/fold-codex/examples/CodexAgent.ts rename to packages/fold-agent/examples/CodexAgent.ts index 5ef8079..ca520cb 100644 --- a/packages/fold-codex/examples/CodexAgent.ts +++ b/packages/fold-agent/examples/CodexAgent.ts @@ -5,18 +5,18 @@ * hidden by the family policy - and streaming rides the hardened first-event/idle timeout + retry * pipeline. * - * Run: bun packages/fold-codex/examples/CodexAgent.ts + * Run: bun packages/fold-agent/examples/CodexAgent.ts */ import { mkdtempSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent' +import { codexModel } from '@humanlayer/fold-codex' import { defineAgent, startSession } from '@humanlayer/fold-core' import { Predicate, Console, Effect } from 'effect' -import { codexModel } from '../src/index' +import { codingTools, jsonlEventLog } from '../src/index' const modelId = process.env.FOLD_CODEX_MODEL ?? 'gpt-5.5' diff --git a/packages/fold-agent/package.json b/packages/fold-agent/package.json index 04b5f77..1935502 100644 --- a/packages/fold-agent/package.json +++ b/packages/fold-agent/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@humanlayer/effect-ai-openai": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "@types/turndown": "catalog:", "effect": "catalog:", diff --git a/packages/fold-agent/scripts/update-managed-binaries.ts b/packages/fold-agent/scripts/update-managed-binaries.ts index 29310e6..9b4f764 100644 --- a/packages/fold-agent/scripts/update-managed-binaries.ts +++ b/packages/fold-agent/scripts/update-managed-binaries.ts @@ -12,7 +12,7 @@ import { writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Effect, Predicate, Schema } from 'effect' +import { Array as Arr, Effect, Predicate, Schema } from 'effect' import { MANAGED_BINARY_PLATFORMS, managedBinaryRegistry } from '../src/Bin/Registry' @@ -105,7 +105,7 @@ const program = Effect.gen(function* () { spawnSync('bunx', ['oxfmt', targetPath], { stdio: 'inherit' }) yield* Effect.log(`wrote ${digests.length} digests to ${targetPath}`) - if (failures.length > 0) { + if (Arr.isArrayNonEmpty(failures)) { yield* Effect.logWarning( `${failures.length} assets could not be hashed and stay UNPINNED (verification skipped at install):\n` + failures.join('\n'), diff --git a/packages/fold-agent/scripts/update-model-catalog.ts b/packages/fold-agent/scripts/update-model-catalog.ts index a19517a..de781ad 100644 --- a/packages/fold-agent/scripts/update-model-catalog.ts +++ b/packages/fold-agent/scripts/update-model-catalog.ts @@ -12,7 +12,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import type { ModelCatalogEntry, ModelPricing } from '@humanlayer/fold-core' -import { Effect, Schema } from 'effect' +import { Array as Arr, Effect, Schema } from 'effect' import { decodeModelsDevModels } from '../src/Catalog/ModelsDevSchema' import { modelCatalogEntriesFromModelsDev } from '../src/Catalog/Normalize' @@ -110,7 +110,7 @@ const main = Effect.gen(function* () { : left.providerId.localeCompare(right.providerId), ) - if (entries.length === 0) { + if (Arr.isArrayEmpty(entries)) { return yield* new CatalogBakeError({ message: 'no anthropic/openai/xai entries survived normalization; refusing to bake', }) diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index c842e54..96fc65c 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -24,7 +24,7 @@ import { accessSync, constants, statSync } from 'node:fs' import { delimiter, join } from 'node:path' import { promisify } from 'node:util' -import { Cause, Effect, FileSystem, Schema } from 'effect' +import { Array as Arr, Cause, Effect, FileSystem, Schema } from 'effect' import { managedBinaryRegistry, type ManagedBinaryAsset, type ManagedBinaryDefinition } from './Registry' @@ -461,7 +461,7 @@ const ensureOnce = ( const registry = options.registry ?? managedBinaryRegistry return yield* Effect.forEach(registry, (definition) => resolveOneNeverFailing(context, definition), { - concurrency: registry.length === 0 ? 1 : registry.length, + concurrency: Arr.isReadonlyArrayEmpty(registry) ? 1 : registry.length, }) }) diff --git a/packages/fold-agent/src/Catalog/LoadCatalog.ts b/packages/fold-agent/src/Catalog/LoadCatalog.ts index 5f0ed9c..8c31a6a 100644 --- a/packages/fold-agent/src/Catalog/LoadCatalog.ts +++ b/packages/fold-agent/src/Catalog/LoadCatalog.ts @@ -14,7 +14,7 @@ import { dirname, join } from 'node:path' import { ModelCatalogEntry } from '@humanlayer/fold-core' -import { Clock, Effect, FileSystem, Predicate, Schema } from 'effect' +import { Array as Arr, Clock, Effect, FileSystem, Predicate, Schema } from 'effect' import { bakedModelCatalog } from './BakedCatalog' import { decodeModelsDevModels, ModelsDevDecodeError } from './ModelsDevSchema' @@ -139,7 +139,7 @@ const fetchCatalogEntries = ( const payload = yield* fetchJson(MODELS_DEV_URL) const models = yield* decodeModelsDevModels(payload) const entries = modelCatalogEntriesFromModelsDev(models) - if (entries.length === 0) { + if (Arr.isReadonlyArrayEmpty(entries)) { // An empty catalog would poison the cache for a full TTL; treat it as a decode failure. return yield* new ModelsDevDecodeError({ message: 'the models.dev payload contained no usable models' }) } diff --git a/packages/fold-agent/src/Catalog/Normalize.ts b/packages/fold-agent/src/Catalog/Normalize.ts index 563da2c..eb137e6 100644 --- a/packages/fold-agent/src/Catalog/Normalize.ts +++ b/packages/fold-agent/src/Catalog/Normalize.ts @@ -7,6 +7,7 @@ * no effort-list constraint (toggle/budget-style reasoning, or no reasoning at all). */ import type { ModelCatalogEntry, ModelPricing } from '@humanlayer/fold-core' +import { Array as Arr } from 'effect' import type { ModelsDevModel, ModelsDevNamedModel } from './ModelsDevSchema' @@ -28,7 +29,7 @@ const reasoningEffortsFrom = (model: ModelsDevModel): ReadonlyArray | nu const efforts = effortOption.values.filter((value): value is string => value !== null) - return efforts.length === 0 ? null : efforts + return Arr.isArrayEmpty(efforts) ? null : efforts } /** diff --git a/packages/fold-agent/src/Compatibility/CodexInstructions.ts b/packages/fold-agent/src/Compatibility/CodexInstructions.ts index 261968e..d6bd0c0 100644 --- a/packages/fold-agent/src/Compatibility/CodexInstructions.ts +++ b/packages/fold-agent/src/Compatibility/CodexInstructions.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Path, Schema } from 'effect' export const CodexInstructionSource = Schema.Struct({ path: Schema.String, @@ -101,7 +101,7 @@ const escapeXmlAttribute = (text: string): string => .replace(/'/g, ''') export const renderCodexInstructions = (sources: ReadonlyArray): string | null => { - if (sources.length === 0) return null + if (Arr.isReadonlyArrayEmpty(sources)) return null return `\n${sources .map( (source) => diff --git a/packages/fold-agent/src/Compatibility/CodexPlugins.ts b/packages/fold-agent/src/Compatibility/CodexPlugins.ts index b6c5b3a..1e037c4 100644 --- a/packages/fold-agent/src/Compatibility/CodexPlugins.ts +++ b/packages/fold-agent/src/Compatibility/CodexPlugins.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Path, Schema } from 'effect' export const CodexPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['config', 'cache', 'manifest']), @@ -73,10 +73,10 @@ const compareVersions = (left: string, right: string): number => { const compared = compareNumeric(leftVersion[field], rightVersion[field]) if (compared !== 0) return compared } - if (leftVersion.prerelease.length === 0 || rightVersion.prerelease.length === 0) { + if (Arr.isArrayEmpty(leftVersion.prerelease) || Arr.isArrayEmpty(rightVersion.prerelease)) { return leftVersion.prerelease.length === rightVersion.prerelease.length ? 0 - : leftVersion.prerelease.length === 0 + : Arr.isArrayEmpty(leftVersion.prerelease) ? 1 : -1 } diff --git a/packages/fold-agent/src/Compatibility/GrokInstructions.ts b/packages/fold-agent/src/Compatibility/GrokInstructions.ts index 92419e5..5226291 100644 --- a/packages/fold-agent/src/Compatibility/GrokInstructions.ts +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Path, Schema } from 'effect' export const GrokInstructionSource = Schema.Struct({ path: Schema.String, @@ -127,21 +127,39 @@ const markdownRules = ( ) }) +const resolveInstructionRoots = ( + options: GrokInstructionOptions, +): Effect.Effect< + { + readonly home: string | null + readonly grokHome: string + readonly directories: ReadonlyArray + readonly ignoreRoot: string | undefined + }, + never, + Path.Path +> => + Effect.gen(function* () { + const path = yield* Path.Path + const cwd = path.resolve(options.cwd) + const homeValue = options.home === undefined ? homedir() : options.home + const home = homeValue.length === 0 ? null : path.resolve(homeValue) + const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) + const explicitRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const explicitRootIsAncestor = explicitRoot !== null && (yield* isAncestor(explicitRoot, cwd)) + const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) + const boundary = explicitRootIsAncestor ? explicitRoot : homeIsAncestor ? home : null + const directories = yield* directoriesToBoundary(cwd, boundary) + const ignoreRoot = explicitRootIsAncestor ? explicitRoot : directories[0] + return { home, grokHome, directories, ignoreRoot } + }) + export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_instructions')(function* ( options: GrokInstructionOptions, ) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const cwd = path.resolve(options.cwd) - const homeValue = options.home === undefined ? homedir() : options.home - const home = homeValue.length === 0 ? null : path.resolve(homeValue) - const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) - const explicitRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) - const explicitRootIsAncestor = explicitRoot !== null && (yield* isAncestor(explicitRoot, cwd)) - const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) - const boundary = explicitRootIsAncestor ? explicitRoot : homeIsAncestor ? home : null - const directories = yield* directoriesToBoundary(cwd, boundary) - const ignoreRoot = explicitRootIsAncestor ? explicitRoot : directories[0] + const { home, grokHome, directories, ignoreRoot } = yield* resolveInstructionRoots(options) const gitignore = ignoreRoot === undefined ? '' @@ -192,7 +210,7 @@ const escapeXmlAttribute = (text: string): string => .replace(/'/g, ''') export const renderGrokInstructions = (sources: ReadonlyArray): string | null => { - if (sources.length === 0) return null + if (Arr.isReadonlyArrayEmpty(sources)) return null return `\n${sources .map( (source) => diff --git a/packages/fold-agent/src/Compatibility/GrokPlugins.ts b/packages/fold-agent/src/Compatibility/GrokPlugins.ts index a467cae..c12eff6 100644 --- a/packages/fold-agent/src/Compatibility/GrokPlugins.ts +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Path, Schema } from 'effect' export const GrokPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['manifest', 'discovery']), @@ -81,28 +81,78 @@ const readManifest = ( return null }) +const resolvePluginParents = (options: GrokPluginOptions): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const cwd = path.resolve(options.cwd) + const homeValue = options.home === undefined ? homedir() : options.home + const home = homeValue.length === 0 ? null : path.resolve(homeValue) + const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) + const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) + const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) + const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? home : null + return [ + ...(options.configuredPaths ?? []).map((configuredPath) => path.resolve(configuredPath)), + ...(yield* ancestorDirectories(cwd, boundary)).flatMap((directory) => [ + path.join(directory, '.grok', 'plugins'), + path.join(directory, '.claude', 'plugins'), + ]), + path.join(grokHome, 'plugins'), + ...(home === null ? [] : [path.join(home, '.claude', 'plugins')]), + ] + }) + +const listPluginCandidates = ( + parent: string, + parentManifest: { path: string; value: unknown } | null, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + if (parentManifest !== null) return [parent] + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + return (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) + .sort((left, right) => left.localeCompare(right)) + .map((entry) => path.join(parent, entry)) + }) + +const pluginName = (manifestValue: Record | null, fallback: string): string => + manifestValue !== null && typeof manifestValue.name === 'string' ? manifestValue.name : fallback + +const declaredSkillPaths = (manifestValue: Record | null): ReadonlyArray => + manifestValue === null || manifestValue.skills === undefined + ? ['skills'] + : Array.isArray(manifestValue.skills) + ? manifestValue.skills + : [manifestValue.skills] + +const resolveSkillRoots = ( + candidate: string, + declared: ReadonlyArray, + manifestPath: string, + diagnostics: Array, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const skillRoots: Array = [] + for (const value of declared) { + const relativePath = safeRelativePath(value) + if (relativePath === null) { + diagnostics.push({ stage: 'manifest', code: 'invalid_skill_root', path: manifestPath }) + continue + } + const skillRoot = path.resolve(candidate, relativePath) + if (yield* fs.exists(skillRoot).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(skillRoot) + } + return skillRoots + }) + export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.discover_plugin_skills')(function* ( options: GrokPluginOptions, ) { - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const cwd = path.resolve(options.cwd) - const homeValue = options.home === undefined ? homedir() : options.home - const home = homeValue.length === 0 ? null : path.resolve(homeValue) - const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) - const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) - const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) - const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) - const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? home : null - const pluginParents = [ - ...(options.configuredPaths ?? []).map((configuredPath) => path.resolve(configuredPath)), - ...(yield* ancestorDirectories(cwd, boundary)).flatMap((directory) => [ - path.join(directory, '.grok', 'plugins'), - path.join(directory, '.claude', 'plugins'), - ]), - path.join(grokHome, 'plugins'), - ...(home === null ? [] : [path.join(home, '.claude', 'plugins')]), - ] + const pluginParents = yield* resolvePluginParents(options) const diagnostics: Array = [] const roots: Array = [] const seenPaths = new Set() @@ -110,12 +160,7 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d for (const parent of pluginParents) { const parentManifest = yield* readManifest(parent) - const candidates = - parentManifest === null - ? (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) - .sort((left, right) => left.localeCompare(right)) - .map((entry) => path.join(parent, entry)) - : [parent] + const candidates = yield* listPluginCandidates(parent, parentManifest) for (const candidate of candidates) { const normalized = path.resolve(candidate) if (seenPaths.has(normalized)) continue @@ -127,32 +172,15 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d continue } const manifestValue = manifest === null || !isRecord(manifest.value) ? null : manifest.value - const name = - manifestValue !== null && typeof manifestValue.name === 'string' - ? manifestValue.name - : path.basename(candidate) + const name = pluginName(manifestValue, path.basename(candidate)) if (name.length === 0 || seenNames.has(name)) continue - const declared = - manifestValue === null || manifestValue.skills === undefined - ? ['skills'] - : Array.isArray(manifestValue.skills) - ? manifestValue.skills - : [manifestValue.skills] - const skillRoots: Array = [] - for (const value of declared) { - const relativePath = safeRelativePath(value) - if (relativePath === null) { - diagnostics.push({ - stage: 'manifest', - code: 'invalid_skill_root', - path: manifest?.path ?? candidate, - }) - continue - } - const skillRoot = path.resolve(candidate, relativePath) - if (yield* fs.exists(skillRoot).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(skillRoot) - } - if (skillRoots.length === 0) continue + const skillRoots = yield* resolveSkillRoots( + candidate, + declaredSkillPaths(manifestValue), + manifest?.path ?? candidate, + diagnostics, + ) + if (Arr.isReadonlyArrayEmpty(skillRoots)) continue seenNames.add(name) for (const skillRoot of skillRoots) roots.push({ name, path: skillRoot }) } diff --git a/packages/fold-agent/src/Config/ConfigSchema.ts b/packages/fold-agent/src/Config/ConfigSchema.ts index e231496..a4eafa4 100644 --- a/packages/fold-agent/src/Config/ConfigSchema.ts +++ b/packages/fold-agent/src/Config/ConfigSchema.ts @@ -14,7 +14,7 @@ */ import { ReasoningLevel } from '@humanlayer/fold-core' import type { AutoCompactConfig as CoreAutoCompactConfig, StopConditionConfig } from '@humanlayer/fold-core' -import { Schema } from 'effect' +import { Array as Arr, Schema } from 'effect' /** How a configured provider profile is reached. */ export const ProviderKind = Schema.Literals(['anthropic', 'openai-compat', 'codex', 'opencode', 'xai']).annotate({ @@ -188,7 +188,7 @@ const providersReferencedByRolesExist = Schema.makeFilter( const declared = new Set(Object.keys(providers)) const bindings = [...bindingsOf(roles), ...Object.values(profiles ?? {}).flatMap(bindingsOf)] const missing = [...new Set(bindings.map((binding) => binding.provider).filter((name) => !declared.has(name)))] - return missing.length === 0 + return Arr.isArrayEmpty(missing) ? undefined : `roles reference undeclared providers: ${missing.join(', ')} (declared: ${[...declared].join(', ') || '(none)'})` }, diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index c992273..0d6da68 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -10,7 +10,7 @@ */ import { join } from 'node:path' -import { JsonSchema, Effect, FileSystem, Schema } from 'effect' +import { Array as Arr, JsonSchema, Effect, FileSystem, Schema } from 'effect' import { FoldConfig } from './ConfigSchema' import { writeFoldInfo } from './FoldInfo' @@ -22,7 +22,7 @@ export const configSchemaPath = (foldHome?: string): string => join(foldHome ?? /** The `FoldConfig` JSON Schema as a self-contained draft-07 document object (ready to serialize). */ export const foldConfigJsonSchema = (): Record => { const document = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(FoldConfig)) - const hasDefinitions = Object.keys(document.definitions).length > 0 + const hasDefinitions = Arr.isArrayNonEmpty(Object.keys(document.definitions)) const schema: Record = { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, ...document.schema, diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index 1d31c72..0f4771e 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -8,7 +8,7 @@ import { dirname } from 'node:path' import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_OPENCODE_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Clock, Effect, FileSystem, Match, Random, Schema } from 'effect' +import { Array as Arr, Clock, Effect, FileSystem, Match, Random, Schema } from 'effect' import type { FoldConfig, ProviderKind } from './ConfigSchema' import { @@ -84,6 +84,31 @@ const validBaseUrl = (value: string): Effect.Effect => + Effect.gen(function* () { + const oauth = input.kind === 'codex' || input.kind === 'opencode' || input.kind === 'xai' + const hasApiKey = input.apiKey !== undefined && input.apiKey.trim() !== '' + const hasApiKeyEnv = input.apiKeyEnv !== undefined && input.apiKeyEnv.trim() !== '' + if (oauth && (hasApiKey || hasApiKeyEnv)) + return yield* new ProviderConfigurationKindError({ + kind: input.kind, + message: `${input.kind} credentials are OAuth-managed; API key options must not be supplied`, + }) + if (!oauth && hasApiKey === hasApiKeyEnv) + return yield* new ProviderConfigurationValidationError({ + field: 'apiKey', + message: 'supply exactly one of apiKey or apiKeyEnv', + }) + const apiKey = !oauth && hasApiKey ? yield* required(input.apiKey ?? '', 'apiKey') : undefined + const apiKeyEnv = !oauth && hasApiKeyEnv ? yield* required(input.apiKeyEnv ?? '', 'apiKeyEnv') : undefined + return { apiKey, apiKeyEnv } + }) + const writeConfig = ( config: FoldConfig, options: LoadConfigOptions | undefined, @@ -125,21 +150,7 @@ export const configureProvider = ( Effect.gen(function* () { const name = yield* required(input.name, 'name') const baseUrl = yield* validBaseUrl(input.baseUrl) - const oauth = input.kind === 'codex' || input.kind === 'opencode' || input.kind === 'xai' - const hasApiKey = input.apiKey !== undefined && input.apiKey.trim() !== '' - const hasApiKeyEnv = input.apiKeyEnv !== undefined && input.apiKeyEnv.trim() !== '' - if (oauth && (hasApiKey || hasApiKeyEnv)) - return yield* new ProviderConfigurationKindError({ - kind: input.kind, - message: `${input.kind} credentials are OAuth-managed; API key options must not be supplied`, - }) - if (!oauth && hasApiKey === hasApiKeyEnv) - return yield* new ProviderConfigurationValidationError({ - field: 'apiKey', - message: 'supply exactly one of apiKey or apiKeyEnv', - }) - const apiKey = !oauth && hasApiKey ? yield* required(input.apiKey ?? '', 'apiKey') : undefined - const apiKeyEnv = !oauth && hasApiKeyEnv ? yield* required(input.apiKeyEnv ?? '', 'apiKeyEnv') : undefined + const { apiKey, apiKeyEnv } = yield* resolveCredentials(input) const defaultModel = Match.value(input.kind).pipe( Match.when('codex', () => DEFAULT_CODEX_MODEL_ID), Match.when('opencode', () => DEFAULT_OPENCODE_MODEL_ID), @@ -162,7 +173,7 @@ export const configureProvider = ( } if (apiKey !== undefined) provider.apiKey = apiKey if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv - if (configuredModels.length > 0) provider.configuredModels = configuredModels + if (Arr.isReadonlyArrayNonEmpty(configuredModels)) provider.configuredModels = configuredModels const updated: FoldConfig = { ...config, providers: { ...config.providers, [name]: provider } } yield* writeConfig(updated, options) diff --git a/packages/fold-agent/src/Memory/AgentFiles.ts b/packages/fold-agent/src/Memory/AgentFiles.ts index 20879ed..4f8e358 100644 --- a/packages/fold-agent/src/Memory/AgentFiles.ts +++ b/packages/fold-agent/src/Memory/AgentFiles.ts @@ -18,7 +18,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { Effect, FileSystem, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Schema } from 'effect' /** One loaded agentfile. */ export const MemoryFile = Schema.Struct({ @@ -124,7 +124,7 @@ const escapeXmlAttribute = (text: string): string => /** Render loaded agentfiles as one `` prompt block (pi shape). Null when empty. */ export const renderMemoryFiles = (files: ReadonlyArray): string | null => { - if (files.length === 0) return null + if (Arr.isReadonlyArrayEmpty(files)) return null const blocks = files.map( (file) => diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 3c9f9d9..251f065 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -39,7 +39,7 @@ import { type Ids, type LogSeq, } from '@humanlayer/fold-core' -import { Predicate, Effect, FileSystem, Layer, Match, Schema, Semaphore, type Scope } from 'effect' +import { Array as Arr, Predicate, Effect, FileSystem, Layer, Match, Schema, Semaphore, type Scope } from 'effect' import { loadModelCatalog, type LoadModelCatalogOptions } from '../Catalog/LoadCatalog' import { @@ -394,7 +394,7 @@ const buildAgentDefinition = ( autoCompact, stopConditions: options.stopConditions ?? config?.stopConditions ?? defaultStopConditions, } - if (blocks.length > 0) agentOptions.systemPrompt = blocks + if (Arr.isArrayNonEmpty(blocks)) agentOptions.systemPrompt = blocks return defineAgent(agentOptions) }) diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts index 0252c67..e33ee6e 100644 --- a/packages/fold-agent/src/Tools/BashTool.ts +++ b/packages/fold-agent/src/Tools/BashTool.ts @@ -236,6 +236,16 @@ const killWithEscalation = (handle: ChildProcessSpawner.ChildProcessHandle): Eff } }) +const validateTimeout = (timeoutMilliseconds: number): Effect.Effect => { + if (!Number.isFinite(timeoutMilliseconds) || timeoutMilliseconds <= 0) { + return Effect.fail({ message: 'Invalid timeout_ms: must be a finite number of milliseconds' }) + } + if (timeoutMilliseconds > maxTimeoutMilliseconds) { + return Effect.fail({ message: `Invalid timeout_ms: maximum is ${maxTimeoutMilliseconds} milliseconds` }) + } + return Effect.succeed(timeoutMilliseconds) +} + /** Build the bash tool. Runs real processes; only spill-file IO goes through the FileSystem seam. */ export const bashTool = (options?: BashToolOptions): FoldTool => defineTool({ @@ -263,18 +273,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool => const configuredCwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) const cwd = params.workdir === undefined ? configuredCwd : yield* resolveToCwd(params.workdir, configuredCwd) - const timeoutMilliseconds = params.timeout_ms ?? defaultTimeoutMilliseconds - - if (!Number.isFinite(timeoutMilliseconds) || timeoutMilliseconds <= 0) { - return yield* Effect.fail({ - message: 'Invalid timeout_ms: must be a finite number of milliseconds', - }) - } - if (timeoutMilliseconds > maxTimeoutMilliseconds) { - return yield* Effect.fail({ - message: `Invalid timeout_ms: maximum is ${maxTimeoutMilliseconds} milliseconds`, - }) - } + const timeoutMilliseconds = yield* validateTimeout(params.timeout_ms ?? defaultTimeoutMilliseconds) if (!(yield* fs.exists(cwd).pipe(Effect.catch(() => Effect.succeed(false))))) { return yield* Effect.fail({ diff --git a/packages/fold-codex/test/CodexImageReadDelivery.vi.test.ts b/packages/fold-agent/test/Codex/CodexImageReadDelivery.vi.test.ts similarity index 97% rename from packages/fold-codex/test/CodexImageReadDelivery.vi.test.ts rename to packages/fold-agent/test/Codex/CodexImageReadDelivery.vi.test.ts index 95d7754..3f5b4fd 100644 --- a/packages/fold-codex/test/CodexImageReadDelivery.vi.test.ts +++ b/packages/fold-agent/test/Codex/CodexImageReadDelivery.vi.test.ts @@ -3,11 +3,11 @@ * the real read tool and session prompt builder, then asserts the intended request emitted with no active tools. */ import { expect, it } from '@effect/vitest' +import { CodexTokenData, makeCodexLanguageModel } from '@humanlayer/fold-codex' +import type { CodexAuthStore } from '@humanlayer/fold-codex' import { Effect, Encoding, Option, Schema } from 'effect' import { FetchHttpClient } from 'effect/unstable/http' -import { CodexTokenData, makeCodexLanguageModel } from '../src/index' -import type { CodexAuthStore } from '../src/index' import { type CapturedFetchRequest, makeCapturingFetch, runImageReadInference } from './SessionModelPathTestHarness' const terminalSse = `data: ${JSON.stringify({ diff --git a/packages/fold-codex/test/CodexLive.vi.test.ts b/packages/fold-agent/test/Codex/CodexLive.vi.test.ts similarity index 98% rename from packages/fold-codex/test/CodexLive.vi.test.ts rename to packages/fold-agent/test/Codex/CodexLive.vi.test.ts index 8e743d0..5ff86de 100644 --- a/packages/fold-codex/test/CodexLive.vi.test.ts +++ b/packages/fold-agent/test/Codex/CodexLive.vi.test.ts @@ -9,9 +9,9 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from '@effect/vitest' +import { makeCodexLanguageModel } from '@humanlayer/fold-codex' import { Effect, Option, Schema, Stream } from 'effect' -import { makeCodexLanguageModel } from '../src/index' import { expectedImageIdentification, runImageReadInference } from './SessionModelPathTestHarness' const authPath = join(homedir(), '.fold', 'auth.json') diff --git a/packages/fold-codex/test/EffectToolResultEncodingRepro.vi.test.ts b/packages/fold-agent/test/Codex/EffectToolResultEncodingRepro.vi.test.ts similarity index 100% rename from packages/fold-codex/test/EffectToolResultEncodingRepro.vi.test.ts rename to packages/fold-agent/test/Codex/EffectToolResultEncodingRepro.vi.test.ts diff --git a/packages/fold-codex/test/SessionModelPathTestHarness.ts b/packages/fold-agent/test/Codex/SessionModelPathTestHarness.ts similarity index 99% rename from packages/fold-codex/test/SessionModelPathTestHarness.ts rename to packages/fold-agent/test/Codex/SessionModelPathTestHarness.ts index c20d660..46247ef 100644 --- a/packages/fold-codex/test/SessionModelPathTestHarness.ts +++ b/packages/fold-agent/test/Codex/SessionModelPathTestHarness.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { deflateSync } from 'node:zlib' import * as NodeServices from '@effect/platform-node/NodeServices' -import { readTool } from '@humanlayer/fold-agent' import { AgentId, buildPrompt, @@ -31,6 +30,8 @@ import { import { Effect, Layer, Predicate, Schema, type Scope, Stream } from 'effect' import { type LanguageModel, type Prompt, Toolkit } from 'effect/unstable/ai' +import { readTool } from '../../src/index' + export const imageIdentificationPrompt = 'Inspect the image returned by read. Name its three vertical color bands from left to right. ' + 'Answer only as left=,middle=,right=.' diff --git a/packages/fold-codex/test/ToolResultFormattingIntegration.vi.test.ts b/packages/fold-agent/test/Codex/ToolResultFormattingIntegration.vi.test.ts similarity index 98% rename from packages/fold-codex/test/ToolResultFormattingIntegration.vi.test.ts rename to packages/fold-agent/test/Codex/ToolResultFormattingIntegration.vi.test.ts index cb3f60e..68a68ce 100644 --- a/packages/fold-codex/test/ToolResultFormattingIntegration.vi.test.ts +++ b/packages/fold-agent/test/Codex/ToolResultFormattingIntegration.vi.test.ts @@ -8,11 +8,11 @@ import { join } from 'node:path' import { assert, it } from '@effect/vitest' import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' -import { bashTool, editTool, readTool, writeTool } from '@humanlayer/fold-agent' import { Effect, Layer, Redacted, Schema } from 'effect' import { LanguageModel } from 'effect/unstable/ai' import { FetchHttpClient } from 'effect/unstable/http' +import { bashTool, editTool, readTool, writeTool } from '../../src/index' import { buildSessionPromptWithToolResults, type CapturedFetchRequest, diff --git a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts index 3482900..bdb6b8c 100644 --- a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts @@ -101,6 +101,21 @@ const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfi return { ...config, roles } } +const expectEveryBindingAtMax = (profiles: ReadonlyArray): void => { + for (const profile of profiles) { + expect(profile).toBeDefined() + if (profile === undefined) continue + for (const binding of profileBindings(profile)) { + expect(binding.reasoning, binding.model).toBe('max') + const model = binding.model + expect(model).toBeDefined() + if (model === undefined) continue + const entry = lookupCatalogEntry(bakedModelCatalog, syntheticOpenAiModel(model)) + expect(entry?.reasoningEfforts, model).toContain('max') + } + } +} + it.effect('the starter config ships the ultraclaude, powerclaude, and ultracodex everything-max RLM presets', () => Effect.gen(function* () { const config = yield* parseFoldConfig(starterConfigJsonc()) @@ -126,18 +141,7 @@ it.effect('the starter config ships the ultraclaude, powerclaude, and ultracodex // Every binding runs at max, and the baked catalog confirms every pinned model supports it - // this is what makes `reasoning: "max"` a known-good shipped default rather than a hope. - for (const profile of [ultraclaude, powerclaude, ultracodex]) { - expect(profile).toBeDefined() - if (profile === undefined) continue - for (const binding of profileBindings(profile)) { - expect(binding.reasoning, binding.model).toBe('max') - const model = binding.model - expect(model).toBeDefined() - if (model === undefined) continue - const entry = lookupCatalogEntry(bakedModelCatalog, syntheticOpenAiModel(model)) - expect(entry?.reasoningEfforts, model).toContain('max') - } - } + expectEveryBindingAtMax([ultraclaude, powerclaude, ultracodex]) }), ) diff --git a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts index f1fcf6e..3487ff1 100644 --- a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -10,6 +10,7 @@ import { createServer, type Server } from 'node:http' import { it } from '@effect/vitest' import { ToolResultMultipart, ToolResultText } from '@humanlayer/fold-core' import { Effect, Schema } from 'effect' +import { constVoid } from 'effect/Function' import { afterAll, beforeAll, expect } from 'vitest' import { webFetchTool } from '../../src/index' @@ -78,7 +79,7 @@ beforeAll(async () => { if (path === '/streamed-huge') { // No content-length: the streaming cap is the only guard. 6MB in 1MB chunks trips it at 5MB. response.writeHead(200, { 'content-type': 'application/octet-stream' }) - response.on('error', () => {}) + response.on('error', constVoid) for (let index = 0; index < 6; index++) response.write(Buffer.alloc(1024 * 1024, index)) response.end() return @@ -89,7 +90,7 @@ beforeAll(async () => { 'content-type': 'application/octet-stream', 'content-length': String(6 * 1024 * 1024), }) - response.on('error', () => {}) + response.on('error', constVoid) response.end(Buffer.alloc(16)) return } diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index 731d027..4bd2488 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -29,7 +29,7 @@ import { import { SessionId, type ModelCatalogEntry } from '@humanlayer/fold-core' import { makeOpenCodeAuth, makeOpenCodeAuthStore, type OpenCodeAuthError } from '@humanlayer/fold-opencode' import { makeXaiAuth, makeXaiAuthStore, type XaiAuthError } from '@humanlayer/fold-xai' -import { Clock, Console, Effect, Option, Schema } from 'effect' +import { Array as Arr, Clock, Console, Effect, Option, Schema } from 'effect' import { type CliError, Command, Flag } from 'effect/unstable/cli' import { FetchHttpClient } from 'effect/unstable/http' @@ -456,7 +456,7 @@ const sessions = Command.make( const sessionOptions: Mutable[0]>> = { cwd } if (foldHome !== undefined) sessionOptions.foldHome = foldHome const sessions = yield* listSessionLogs(sessionOptions) - if (sessions.length === 0) { + if (Arr.isReadonlyArrayEmpty(sessions)) { yield* Console.log(`No fold sessions for ${cwd}`) return } @@ -932,7 +932,7 @@ const withErrorHandling = (effect: Effect.Effect): UserError: (error) => printFailure(error.message), ShowHelp: (error) => Effect.sync(() => { - process.exitCode = error.errors.length === 0 ? 0 : 1 + process.exitCode = Arr.isReadonlyArrayEmpty(error.errors) ? 0 : 1 }), InvalidSessionIdError: (error: InvalidSessionIdError) => printFailure(`invalid --resume value "${error.value}"; pass "latest" or an exact sess_... id`), @@ -947,7 +947,7 @@ const withErrorHandling = (effect: Effect.Effect): RoleResolutionError: (error) => printFailure(error.message), UnknownProfileError: (error) => printFailure( - `unknown profile "${error.profile}"; available: ${error.available.length === 0 ? '(none configured)' : error.available.join(', ')}`, + `unknown profile "${error.profile}"; available: ${Arr.isReadonlyArrayEmpty(error.available) ? '(none configured)' : error.available.join(', ')}`, ), NoSessionToResumeError: (error) => printFailure(`no fold sessions exist for ${error.cwd}`), SessionToResumeNotFoundError: (error) => diff --git a/packages/fold-cli/src/Renderer.ts b/packages/fold-cli/src/Renderer.ts index bfe4257..af161d7 100644 --- a/packages/fold-cli/src/Renderer.ts +++ b/packages/fold-cli/src/Renderer.ts @@ -17,7 +17,7 @@ import { type UsageEncoded, type FoldEvent, } from '@humanlayer/fold-core' -import { Data, Effect, Match } from 'effect' +import { Array as Arr, Data, Effect, Match } from 'effect' import { makeAnsiPalette, type AnsiPalette } from './Ansi' import { contextUsedPercentForDisplay, contextWindowLimitForDisplay } from './ContextWindow' @@ -283,7 +283,7 @@ const resumeCommand = ( input: { readonly model: ActiveModel | null; readonly flags: ReadonlyArray }, ): string => { const flags = [`--resume ${shellQuote(sessionId)}`] - if (input.flags.length > 0) { + if (Arr.isReadonlyArrayNonEmpty(input.flags)) { flags.push(...input.flags.map(formattedFlag)) } else if (input.model !== null) { const model = input.model diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index 5edd9df..7267963 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -28,6 +28,7 @@ import type { FoldSession, } from '@humanlayer/fold-core' import { + Array as Arr, Data, Match, Predicate, @@ -234,7 +235,10 @@ const renderLiveEvents = ( ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { const entries = yield* session.entries - const fromSeq = entries.length === 0 ? 0 : (entries.at(-1)?.seq ?? -1) + 1 + const fromSeq = Arr.match(entries, { + onEmpty: () => 0, + onNonEmpty: (nonEmpty) => Arr.lastNonEmpty(nonEmpty).seq + 1, + }) const render = session.events(fromSeq).pipe( Stream.runForEach(renderer.renderEvent), Effect.catchCause(() => Effect.void), diff --git a/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx b/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx index b28863b..720ceca 100644 --- a/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx @@ -2,6 +2,7 @@ import type { ModelConfiguration } from '@humanlayer/fold-agent' /** @jsxImportSource @opentui/solid */ import { createCliRenderer } from '@opentui/core' import { render } from '@opentui/solid' +import { constVoid } from 'effect/Function' import { createSignal, Show } from 'solid-js' import { ProviderConfigPage } from '../../src/tui/ProviderConfigPage' @@ -62,9 +63,9 @@ await render( sessions={sessions} notice={notice} opening={() => false} - onOpen={() => {}} - onDelete={() => {}} - onNew={() => {}} + onOpen={constVoid} + onDelete={constVoid} + onNew={constVoid} onOpenProviders={() => setProvidersOpen(true)} onQuit={() => renderer.destroy()} /> @@ -81,7 +82,7 @@ await render( authStatus: action === 'logout' ? 'logged-out' : 'logged-in', }) } - onInitialize={() => {}} + onInitialize={constVoid} onCopyUrl={() => true} onConfigure={(input, update) => { setConfiguration((current) => ({ diff --git a/packages/fold-codex/package.json b/packages/fold-codex/package.json index f008d7d..bd9c093 100644 --- a/packages/fold-codex/package.json +++ b/packages/fold-codex/package.json @@ -25,7 +25,6 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@humanlayer/fold-agent": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "effect": "catalog:", "typescript": "catalog:", diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index dd714b2..09ff327 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -18,7 +18,7 @@ import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' import type * as OpenAiSchema from '@humanlayer/effect-ai-openai/OpenAiSchema' import { customModel, resolveCodexReasoning } from '@humanlayer/fold-core' import type { ReasoningLevel, FoldModel } from '@humanlayer/fold-core' -import { Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' +import { Array as Arr, Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' import type { Scope } from 'effect' import { AiError } from 'effect/unstable/ai' import type { LanguageModel } from 'effect/unstable/ai' @@ -80,7 +80,7 @@ export const liftLeadingSystemIntoInstructions = (payload: ResponsesPayload): Re if (Option.isNone(decoded)) break leading.push(decoded.value.content) } - if (leading.length === 0) return payload + if (Arr.isArrayEmpty(leading)) return payload return { ...payload, instructions: leading.join('\n\n'), input: input.slice(leading.length) } } @@ -198,8 +198,9 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR // storage); the finished items arrive as `response.output_item.done` events, so // graft them in. A terminal response that does carry output (standard OpenAI // behavior) wins as-is. - const body: ResponseBody = - terminal.value.output.length > 0 ? terminal.value : { ...terminal.value, output: items } + const body: ResponseBody = Arr.isReadonlyArrayNonEmpty(terminal.value.output) + ? terminal.value + : { ...terminal.value, output: items } const result: readonly [ResponseBody, HttpClientResponse.HttpClientResponse] = [ body, diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 8564b8c..5119d56 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -40,6 +40,7 @@ import { observeDoomLoop, StopConditions, type DoomLoopState, + type ToolCallFingerprintInput, } from '../StopConditions/StopConditions' import { SystemPrompt } from '../SystemPrompt/SystemPromptService' import { StopController, type StopControllerService } from '../ToolRuntime/ToolContextServices' @@ -206,8 +207,10 @@ export const liveAgentRuntimeLayer: Layer.Layer< const foldId = yield* ids.makeToolCallId content.push( Prompt.toolCallPart({ - ...part, id: foldId, + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, options: { ...part.options, [foldPartOptionsKey]: { [providerToolCallIdKey]: part.id } }, }), ) @@ -281,6 +284,44 @@ export const liveAgentRuntimeLayer: Layer.Layer< return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) }) + /** Settle one assistant message's tool calls, then decide whether the run stops or continues. */ + const settleToolCalls = ( + input: RunAgentInput, + persistedAssistant: Prompt.AssistantMessage, + toolCalls: ReadonlyArray, + doomLoopRef: Ref.Ref, + ): Effect.Effect => + Effect.gen(function* () { + const doomLoop = observeDoomLoop(stopConditions, yield* Ref.get(doomLoopRef), toolCalls) + yield* Ref.set(doomLoopRef, doomLoop.state) + + const settlement = yield* toolRuntime.settle({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + assistantMessage: persistedAssistant, + }) + + if (settlement.stopRequested) { + const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') + return TurnResult.finished({ entry }) + } + + // D9: a session-wide stop lets the in-flight batch finish and its results land (above), + // then ends the run here - no further LLM call. + const sessionStopAfterBatch = yield* sessionControls.sessionStopReason + if (sessionStopAfterBatch !== null) { + const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) + return TurnResult.finished({ entry }) + } + + if (doomLoop.reason !== null) { + const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) + return TurnResult.finished({ entry }) + } + + return TurnResult.continue() + }) + /** Run one model turn: build the request, call the model, persist, and settle tool calls. */ const runTurn = ( input: RunAgentInput, @@ -447,37 +488,10 @@ export const liveAgentRuntimeLayer: Layer.Layer< const toolCalls = persistedAssistant.content.flatMap((part) => part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], ) - const hasToolCalls = toolCalls.length > 0 + const hasToolCalls = Arr.isArrayNonEmpty(toolCalls) if (hasToolCalls) { - const doomLoop = observeDoomLoop(stopConditions, yield* Ref.get(doomLoopRef), toolCalls) - yield* Ref.set(doomLoopRef, doomLoop.state) - - const settlement = yield* toolRuntime.settle({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - assistantMessage: persistedAssistant, - }) - - if (settlement.stopRequested) { - const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') - return TurnResult.finished({ entry }) - } - - // D9: a session-wide stop lets the in-flight batch finish and its results land (above), - // then ends the run here - no further LLM call. - const sessionStopAfterBatch = yield* sessionControls.sessionStopReason - if (sessionStopAfterBatch !== null) { - const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) - return TurnResult.finished({ entry }) - } - - if (doomLoop.reason !== null) { - const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) - return TurnResult.finished({ entry }) - } - - return TurnResult.continue() + return yield* settleToolCalls(input, persistedAssistant, toolCalls, doomLoopRef) } yield* Ref.set(doomLoopRef, initialDoomLoopState) @@ -502,7 +516,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< // D8: follow-ups queued while this agent was running drain exactly where the run would // complete naturally - each becomes an ordinary user-message and the run continues. const followUps = yield* sessionControls.drainFollowUps(input.agentId) - if (followUps.length > 0) { + if (Arr.isReadonlyArrayNonEmpty(followUps)) { for (const text of followUps) { yield* appendUserMessage(input, text) } @@ -567,7 +581,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< fork: input.fork, agentType: input.agentType, } - if (input.promptCacheKey != null) { + if (input.promptCacheKey !== undefined && input.promptCacheKey !== null) { agentStartedInput.promptCacheKey = input.promptCacheKey } const entry = yield* appendToEventLog(LogEntryInputs['agent_started'](agentStartedInput)) diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index d9e277d..7a55a41 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -18,7 +18,7 @@ */ import { AnthropicClient, AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' -import { Context, Effect, Layer, Match, Stream } from 'effect' +import { Array as Arr, Context, Effect, Layer, Match, Stream } from 'effect' import type { Scope } from 'effect' import { LanguageModel, Toolkit } from 'effect/unstable/ai' import type { Tool } from 'effect/unstable/ai' @@ -149,7 +149,7 @@ export const validateToolNames = (tools: ReadonlyArray): Effect.Effect ...new Set(tools.map((tool) => tool.name).filter((name, index, names) => names.indexOf(name) !== index)), ] - return duplicates.length === 0 + return Arr.isArrayEmpty(duplicates) ? Effect.void : Effect.die(new Error(`duplicate tool names: ${duplicates.join(', ')}`)) } diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 17b9132..89b547f 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -28,6 +28,7 @@ * send. */ import { + Array as Arr, Predicate, Cause, Context, @@ -450,7 +451,7 @@ const assembleSessionGraph = (options: { tools: ReadonlyArray, ): ReadonlyArray | null => { const blocks = [...promptBlocksOf(systemPrompt), ...realizeAgentTools(tools).promptBlocks] - return blocks.length === 0 ? null : blocks + return Arr.isArrayEmpty(blocks) ? null : blocks } const initialConfig: SessionAgentConfig = { diff --git a/packages/fold-core/src/Compaction/CompactionEngine.ts b/packages/fold-core/src/Compaction/CompactionEngine.ts index ba01b57..ca7a8f5 100644 --- a/packages/fold-core/src/Compaction/CompactionEngine.ts +++ b/packages/fold-core/src/Compaction/CompactionEngine.ts @@ -1,4 +1,4 @@ -import { Match, Predicate } from 'effect' +import { Array as Arr, Match, Predicate } from 'effect' /** * This file is the pure auto-compaction engine (D11): the threshold arithmetic over API-reported @@ -189,7 +189,7 @@ export const findCompactionCutPlan = ( messages: ReadonlyArray, keepRecentTokens: number, ): CompactionCut => { - if (messages.length === 0) return { firstKeptIndex: 0, turnStartIndex: -1, isSplitTurn: false } + if (Arr.isReadonlyArrayEmpty(messages)) return { firstKeptIndex: 0, turnStartIndex: -1, isSplitTurn: false } let accumulated = 0 let boundary = -1 @@ -282,10 +282,10 @@ export const serializeConversation = (messages: ReadonlyArray) for (const part of reasoning) { lines.push(`[Assistant thinking]: ${part.text ?? ''}`) } - if (text.length > 0) { + if (Arr.isArrayNonEmpty(text)) { lines.push(`[Assistant]: ${text.map((part) => part.text ?? '').join('\n')}`) } - if (toolCalls.length > 0) { + if (Arr.isArrayNonEmpty(toolCalls)) { lines.push( `[Assistant tool calls]: ${toolCalls .map((part) => `${part.name ?? 'tool'}(${safeStringify(part.params)})`) diff --git a/packages/fold-core/src/Compaction/CompactionLayer.ts b/packages/fold-core/src/Compaction/CompactionLayer.ts index 5696737..4586337 100644 --- a/packages/fold-core/src/Compaction/CompactionLayer.ts +++ b/packages/fold-core/src/Compaction/CompactionLayer.ts @@ -9,7 +9,7 @@ */ import { AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' import { OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' -import { Effect, Predicate, Stream } from 'effect' +import { Array as Arr, Effect, Predicate, Stream } from 'effect' import { LanguageModel, Prompt } from 'effect/unstable/ai' import { ModelCatalog } from '../Model/ModelCatalog' @@ -205,7 +205,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact replacedMessages: discarded.length, keptMessages: conversation.length - discarded.length, splitTurn: cut.isSplitTurn, - summaryCalls: cut.isSplitTurn ? (toSummarize.length > 0 ? 2 : 1) : 1, + summaryCalls: cut.isSplitTurn ? (Arr.isArrayNonEmpty(toSummarize) ? 2 : 1) : 1, }) const requestText = buildCompactionRequestText({ @@ -220,10 +220,9 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact additionalInstructions: input.additionalInstructions ?? null, }) - const historySummary = - toSummarize.length > 0 - ? yield* summarize(input, requestText, summaryOutputFraction) - : 'No prior history.' + const historySummary = Arr.isArrayNonEmpty(toSummarize) + ? yield* summarize(input, requestText, summaryOutputFraction) + : 'No prior history.' const summary = cut.isSplitTurn ? `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${yield* summarize( input, diff --git a/packages/fold-core/src/EventLog/EventLogService.ts b/packages/fold-core/src/EventLog/EventLogService.ts index b8223c7..6c80b71 100644 --- a/packages/fold-core/src/EventLog/EventLogService.ts +++ b/packages/fold-core/src/EventLog/EventLogService.ts @@ -1,8 +1,8 @@ import { Context } from 'effect' import type { Effect, Stream } from 'effect' -import type { EventLogError } from './Errors.ts' -import type { LogEntry, LogEntryInput, LogSeq } from './Schemas.ts' +import type { EventLogError } from './Errors' +import type { LogEntry, LogEntryInput, LogSeq } from './Schemas' /** Public EventLog service surface for a single fold session log. */ export type EventLogService = { diff --git a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts index 4265fb0..7cba653 100644 --- a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts +++ b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts @@ -23,7 +23,7 @@ import type { PreToolUseHookDecision, PreToolUseHookInput, } from './Schema' -import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types.ts' +import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types' type HookDecision = PreRequestHookDecision | PreToolUseHookDecision | PostToolUseHookDecision | OnCompleteHookDecision const HookDecision = Data.taggedEnum() diff --git a/packages/fold-core/src/HookRunner/Types.ts b/packages/fold-core/src/HookRunner/Types.ts index 44c0d97..cfc825a 100644 --- a/packages/fold-core/src/HookRunner/Types.ts +++ b/packages/fold-core/src/HookRunner/Types.ts @@ -11,7 +11,7 @@ import type { PreRequestHookInput, PreToolUseHookDecision, PreToolUseHookInput, -} from './Schema.ts' +} from './Schema' /** * Ambient services every hook handler may use. diff --git a/packages/fold-core/src/Model/RequestBuilder.ts b/packages/fold-core/src/Model/RequestBuilder.ts index e217b3f..74df294 100644 --- a/packages/fold-core/src/Model/RequestBuilder.ts +++ b/packages/fold-core/src/Model/RequestBuilder.ts @@ -8,7 +8,7 @@ * metadata into history. The assistant tool-call params stay exactly as decoded from the persisted * assistant message, keeping already-sent prompt bytes stable across turns. */ -import { Effect, Encoding, Match, Option, Predicate, Schema } from 'effect' +import { Array as Arr, Effect, Encoding, Match, Option, Predicate, Schema } from 'effect' import { Prompt } from 'effect/unstable/ai' import type { ProjectedMessage, ProjectedToolResult } from '../Projection/Projection' @@ -67,7 +67,13 @@ const restoreAssistantToolCallIds = ( if (providerId === null) return part providerIdsByFoldId.set(part.id, providerId) - return Prompt.toolCallPart({ ...part, id: providerId }) + return Prompt.toolCallPart({ + id: providerId, + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, + options: part.options, + }) }), options: message.options, }) @@ -84,7 +90,14 @@ const restoreToolResultIds = ( const providerId = providerIdsByFoldId.get(part.id) if (providerId === undefined) return part - return Prompt.toolResultPart({ ...part, id: providerId }) + return Prompt.toolResultPart({ + id: providerId, + name: part.name, + isFailure: part.isFailure, + result: part.result, + providerExecuted: part.providerExecuted, + options: part.options, + }) }), options: message.options, }) @@ -246,7 +259,18 @@ const prepareToolResult = (result: unknown): Effect.Effect => Effect.forEach(message.content, (part): Effect.Effect => part.type === 'tool-result' - ? prepareToolResult(part.result).pipe(Effect.map((result) => Prompt.toolResultPart({ ...part, result }))) + ? prepareToolResult(part.result).pipe( + Effect.map((result) => + Prompt.toolResultPart({ + id: part.id, + name: part.name, + isFailure: part.isFailure, + result, + providerExecuted: part.providerExecuted, + options: part.options, + }), + ), + ) : Effect.succeed(part), ).pipe(Effect.map((content) => Prompt.toolMessage({ content, options: message.options }))) @@ -276,7 +300,7 @@ export const buildPrompt = ( const toolCalls = unresolvedToolCallsFromAssistantMessage(assistant) promptMessages.push(restoreAssistantToolCallIds(assistant, providerIdsByFoldId)) - if (toolCalls.length === 0) continue + if (Arr.isReadonlyArrayEmpty(toolCalls)) continue const results: Array = [] while (true) { diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 8085470..783a7a7 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -1,4 +1,4 @@ -import { Data, Match, Predicate } from 'effect' +import { Array as Arr, Data, Match, Predicate } from 'effect' import type { ActiveModel, @@ -13,8 +13,8 @@ import type { ToolMessageEncoded, ToolResultLogEntry, UserMessageLogEntry, -} from '../EventLog/Schemas.ts' -import type { AgentId } from '../Ids.ts' +} from '../EventLog/Schemas' +import type { AgentId } from '../Ids' /** Read model for an agent's lifecycle: how it started, whether it has finished, and whether it is runnable. */ export type AgentLifecycleProjection = { @@ -109,7 +109,10 @@ const isInjectedSkillMessage = (entry: LogEntry): boolean => { const isSettledAssistantText = (entry: LogEntry): boolean => { if (!Predicate.isTagged(entry, 'assistant-message')) return false if (typeof entry.message.content === 'string') return entry.message.content.trim().length > 0 - return entry.message.content.length > 0 && entry.message.content.every((part) => part.type === 'text') + return ( + Arr.isReadonlyArrayNonEmpty(entry.message.content) && + entry.message.content.every((part) => part.type === 'text') + ) } const eligibleForkHistory = ( @@ -295,7 +298,7 @@ const orderProjectedToolResults = (messages: ReadonlyArray): R if (!Predicate.isTagged(message, 'assistant-message')) continue const toolCallIds = toolCallIdsForAssistantMessage(message.message) - if (toolCallIds.length === 0) continue + if (Arr.isReadonlyArrayEmpty(toolCallIds)) continue const toolResults: Array = [] while (true) { diff --git a/packages/fold-core/src/Session/SessionControls.ts b/packages/fold-core/src/Session/SessionControls.ts index 4b41c00..01fb994 100644 --- a/packages/fold-core/src/Session/SessionControls.ts +++ b/packages/fold-core/src/Session/SessionControls.ts @@ -15,7 +15,7 @@ * its batch boundaries (root and subagents alike - the tree stops together), cleared by the facade * when the next send begins. */ -import { Context, Deferred, Effect, type Exit, Fiber, Ref, SynchronizedRef } from 'effect' +import { Array as Arr, Context, Deferred, Effect, type Exit, Fiber, Ref, SynchronizedRef } from 'effect' import type { AgentFinishedLogEntry } from '../EventLog/Schemas' import type { AgentId } from '../Ids' @@ -175,12 +175,12 @@ export const makeSessionControls = (options?: { const drainSteering = (agentId: AgentId): Effect.Effect> => Ref.modify(steering, (current) => { const queued = current.get(agentId) ?? [] - if (queued.length === 0) return [[], current] as const + if (Arr.isReadonlyArrayEmpty(queued)) return [[], current] as const const drained = steeringMode === 'all' ? queued : queued.slice(0, 1) const remaining = steeringMode === 'all' ? [] : queued.slice(1) const next = new Map(current) - if (remaining.length === 0) next.delete(agentId) + if (Arr.isReadonlyArrayEmpty(remaining)) next.delete(agentId) else next.set(agentId, remaining) return [drained, next] as const }) diff --git a/packages/fold-core/src/Skills/SkillTool.ts b/packages/fold-core/src/Skills/SkillTool.ts index b1cc247..4521a92 100644 --- a/packages/fold-core/src/Skills/SkillTool.ts +++ b/packages/fold-core/src/Skills/SkillTool.ts @@ -5,7 +5,7 @@ * New skills become discoverable through the tool's `refresh` flag, which diffs the live list against * the session-start snapshot and reports changes inside the tool result (cache-neutral). */ -import { Effect } from 'effect' +import { Array as Arr, Effect } from 'effect' import { defineTool, type SessionToolContribution, type FoldTool } from '../Api/ToolDefinition' import { skillToolContract } from '../Tools/Contracts' @@ -36,7 +36,7 @@ const renderSkillList = (skills: ReadonlyArray): string => * roster is empty so no block is written. */ export const renderSkillsBlock = (skills: ReadonlyArray): string | null => { - if (skills.length === 0) return null + if (Arr.isReadonlyArrayEmpty(skills)) return null return ( 'The following skills provide specialized instructions and workflows for specific tasks.\n' + @@ -103,10 +103,9 @@ type SkillToolFailure = ToolResultFailure */ export const makeSkillTool = (input: MakeSkillToolInput): FoldTool => { const snapshotNames = input.snapshot.map((meta) => meta.name) - const rosterSuffix = - snapshotNames.length === 0 - ? ' No skills were available when this session started; call with refresh: true to re-scan.' - : ` Available skills: ${snapshotNames.join(', ')}.` + const rosterSuffix = Arr.isArrayEmpty(snapshotNames) + ? ' No skills were available when this session started; call with refresh: true to re-scan.' + : ` Available skills: ${snapshotNames.join(', ')}.` return defineTool({ ...skillToolContract, @@ -121,7 +120,9 @@ export const makeSkillTool = (input: MakeSkillToolInput): FoldTool => { Effect.fail( ToolResultFailure.make({ text: `Skill "${params.name}" not found. Available skills: ${ - error.availableSkills.length === 0 ? '(none)' : error.availableSkills.join(', ') + Arr.isReadonlyArrayEmpty(error.availableSkills) + ? '(none)' + : error.availableSkills.join(', ') }`, details: { availableSkills: error.availableSkills }, }), @@ -159,15 +160,17 @@ const refreshedRoster = (input: MakeSkillToolInput) => const added = current.filter((meta) => !snapshotNames.has(meta.name)) const removed = input.snapshot.filter((meta) => !currentNames.has(meta.name)) - if (added.length === 0 && removed.length === 0) { + if (Arr.isArrayEmpty(added) && Arr.isArrayEmpty(removed)) { return 'The skill list has not changed since this session started.' } const addedLines = added.map((meta) => `- ${meta.name}: ${meta.description}`) const removedLines = removed.map((meta) => `- ${meta.name}`) const sections = [ - ...(addedLines.length === 0 ? [] : [`Skills added since session start:\n${addedLines.join('\n')}`]), - ...(removedLines.length === 0 ? [] : [`Skills removed since session start:\n${removedLines.join('\n')}`]), + ...(Arr.isArrayEmpty(addedLines) ? [] : [`Skills added since session start:\n${addedLines.join('\n')}`]), + ...(Arr.isArrayEmpty(removedLines) + ? [] + : [`Skills removed since session start:\n${removedLines.join('\n')}`]), ] return `\n${sections.join('\n\n')}\n` diff --git a/packages/fold-core/src/StopConditions/StopConditions.ts b/packages/fold-core/src/StopConditions/StopConditions.ts index fdf52af..2f8e313 100644 --- a/packages/fold-core/src/StopConditions/StopConditions.ts +++ b/packages/fold-core/src/StopConditions/StopConditions.ts @@ -4,7 +4,7 @@ * emits the same tool-call batch repeatedly, fold lets the current batch settle, then stops gracefully * before another model request. */ -import { Context } from 'effect' +import { Array as Arr, Context } from 'effect' /** Doom-loop detector configuration. Omitted means disabled. */ export type DoomLoopStopCondition = @@ -69,7 +69,7 @@ export const observeDoomLoop = ( state: DoomLoopState, toolCalls: ReadonlyArray, ): DoomLoopObservation => { - if (config.doomLoop === undefined || !config.doomLoop.enabled || toolCalls.length === 0) { + if (config.doomLoop === undefined || !config.doomLoop.enabled || Arr.isReadonlyArrayEmpty(toolCalls)) { return { state: initialDoomLoopState, reason: null } } diff --git a/packages/fold-core/src/Subagents/AgentIdRef.ts b/packages/fold-core/src/Subagents/AgentIdRef.ts index 2586487..e0885c8 100644 --- a/packages/fold-core/src/Subagents/AgentIdRef.ts +++ b/packages/fold-core/src/Subagents/AgentIdRef.ts @@ -9,7 +9,7 @@ * cannot themselves be full ids (4-20 characters - a full cuid segment is 21-32). The 4-char floor * matches the CLI renderer's tag suffix, so an id read off a tag is always a valid reference. */ -import { Data, Predicate, Schema } from 'effect' +import { Array as Arr, Data, Predicate, Schema } from 'effect' import type { LogEntry } from '../EventLog/Schemas' import type { AgentId } from '../Ids' @@ -76,7 +76,7 @@ export const resolveAgentIdRef = (knownIds: Iterable, ref: string): Age const matches = ids.filter((id) => cuidSegmentOf(id).startsWith(wanted)) const [single] = matches if (matches.length === 1 && single !== undefined) return AgentIdRefResolution.resolved({ agentId: single }) - if (matches.length === 0) return AgentIdRefResolution['not-found']() + if (Arr.isArrayEmpty(matches)) return AgentIdRefResolution['not-found']() return AgentIdRefResolution.ambiguous({ candidates: matches.map(shortAgentId) }) } diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index d2c608f..9fae76f 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -8,7 +8,7 @@ * the tool's instructive failure payload with `catchTag`/`catchTags` - all choreography lives in the * deep module, and no error is ever inspected as a value. */ -import { Effect, Match } from 'effect' +import { Array as Arr, Effect, Match } from 'effect' import { defineTool, type FoldTool } from '../Api/ToolDefinition' import type { SkillNotFoundError } from '../Skills/SkillSource' @@ -90,7 +90,7 @@ const rosterFailure = (error: SubagentTypeNotInRosterError): SubagentToolFailure ToolResultFailure.make({ text: `Agent type "${error.requested}" is not available to you. Available agent types: ` + - `${error.availableAgents.length === 0 ? '(none)' : error.availableAgents.join(', ')}.`, + `${Arr.isReadonlyArrayEmpty(error.availableAgents) ? '(none)' : error.availableAgents.join(', ')}.`, details: { availableAgents: error.availableAgents }, }) @@ -99,7 +99,7 @@ const skillFailure = (error: SkillNotFoundError, allowedAgents: ReadonlyArray): SubagentToolFailure => ToolResultFailure.make({ text: - error.candidates === undefined || error.candidates.length === 0 + error.candidates === undefined || Arr.isReadonlyArrayEmpty(error.candidates) ? `No subagent with agent_id "${error.requested}" exists in this session. Use the agent_id from a ` + `previous subagent result, or dispatch a fresh agent with the agent parameter.` : `agent_id "${error.requested}" is ambiguous: it matches ${error.candidates.length} agents ` + diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index c253eba..0a98d4e 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -14,8 +14,7 @@ * `setProfile` swap binds on the very next run and the existing transition diff sees only concrete * models. */ -import { Data, Match, Predicate, Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from 'effect' -import type { Array as Arr } from 'effect' +import { Array as Arr, Data, Match, Predicate, Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import type { FoldModel } from '../Api/ModelDescriptor' @@ -145,7 +144,7 @@ const leadingBlocksFor = ( realized: RealizedAgentTools, ): ReadonlyArray | null => { const blocks = [...promptBlocksOf(systemPrompt), ...realized.promptBlocks] - return blocks.length === 0 ? null : blocks + return Arr.isArrayEmpty(blocks) ? null : blocks } /** diff --git a/packages/fold-core/src/ToolRuntime/ToolContextServices.ts b/packages/fold-core/src/ToolRuntime/ToolContextServices.ts index 1271020..6264311 100644 --- a/packages/fold-core/src/ToolRuntime/ToolContextServices.ts +++ b/packages/fold-core/src/ToolRuntime/ToolContextServices.ts @@ -5,7 +5,7 @@ */ import { Context, Effect, type Schema } from 'effect' -import type { AgentId, ToolCallId } from '../Ids.ts' +import type { AgentId, ToolCallId } from '../Ids' /** UI/progress event emitted by a running tool. This is not the durable final tool result. */ export type ToolRuntimeEvent = { diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts index 3ea1675..07002bc 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts @@ -8,8 +8,8 @@ import { Context } from 'effect' import type { Effect } from 'effect' import type { Prompt } from 'effect/unstable/ai' -import type { ToolResultLogEntry } from '../EventLog/Schemas.ts' -import type { AgentId } from '../Ids.ts' +import type { ToolResultLogEntry } from '../EventLog/Schemas' +import type { AgentId } from '../Ids' /** Result of settling all tool calls from one persisted assistant message. */ export type ToolSettlement = { diff --git a/packages/fold-core/src/Tools/EditEngine.ts b/packages/fold-core/src/Tools/EditEngine.ts index 891b1fd..88c967f 100644 --- a/packages/fold-core/src/Tools/EditEngine.ts +++ b/packages/fold-core/src/Tools/EditEngine.ts @@ -6,7 +6,7 @@ * byte-identical even when a normalized match was needed; BOM and CRLF endings are preserved. Error * strings are pi's, verbatim. Pure and isomorphic: platform handlers do the file IO around it. */ -import { Effect, Schema } from 'effect' +import { Array as Arr, Effect, Schema } from 'effect' /** One targeted replacement: exact old text and its replacement. */ export type EditPair = { @@ -340,7 +340,7 @@ export const normalizeEditInput = (input: { edits.push({ oldText: input.oldText, newText: input.newText }) } - if (edits.length === 0) { + if (Arr.isArrayEmpty(edits)) { return yield* new EditEngineError({ message: 'Edit tool input is invalid. edits must contain at least one replacement.', }) diff --git a/packages/fold-core/src/Tools/PatchEngine.ts b/packages/fold-core/src/Tools/PatchEngine.ts index 39f52f4..ec2c7bd 100644 --- a/packages/fold-core/src/Tools/PatchEngine.ts +++ b/packages/fold-core/src/Tools/PatchEngine.ts @@ -6,7 +6,7 @@ * 4-pass line matcher (exact, rstrip, trim, unicode-fold) - plus clanka's strict superset of accepting * raw git/unified diffs. Failures are typed tagged errors, not defects (contrast clanka's orDie). */ -import { Data, Effect, Match, Schema } from 'effect' +import { Array as Arr, Data, Effect, Match, Schema } from 'effect' /** The patch text could not be parsed into file operations. */ export class PatchParseError extends Schema.TaggedError()('PatchParseError', { @@ -115,7 +115,7 @@ export const seekSequence = ( startIndex: number, eof: boolean, ): number => { - if (pattern.length === 0) return -1 + if (Arr.isReadonlyArrayEmpty(pattern)) return -1 for (const compare of matchPasses) { if (eof) { @@ -172,6 +172,57 @@ const feedChunkLine = (state: ChunkParseState, line: string): void => { } } +type ParsedV4AOp = { readonly op: PatchOp; readonly nextIndex: number } + +/** Parse an `*** Add File` section starting at `start`; only `+` lines contribute content. */ +const parseV4AAdd = (lines: ReadonlyArray, start: number, endIndex: number): ParsedV4AOp => { + const path = (lines[start] ?? '').slice(addMarker.length).trim() + const content: Array = [] + let index = start + 1 + while (index < endIndex && !(lines[index] ?? '').startsWith('***')) { + const contentLine = lines[index] ?? '' + if (contentLine.startsWith('+')) content.push(contentLine.slice(1)) + index += 1 + } + return { op: PatchOp.add({ path, content: content.join('\n') }), nextIndex: index } +} + +/** Parse an `*** Update File` section starting at `start`, including an optional move and its hunks. */ +const parseV4AUpdate = (lines: ReadonlyArray, start: number, endIndex: number): ParsedV4AOp => { + const path = (lines[start] ?? '').slice(updateMarker.length).trim() + let index = start + 1 + + let movePath: string | null = null + if (index < endIndex && (lines[index] ?? '').startsWith(moveMarker)) { + movePath = (lines[index] ?? '').slice(moveMarker.length).trim() + index += 1 + } + + const state: ChunkParseState = { chunks: [], current: emptyChunk() } + while (index < endIndex) { + const bodyLine = lines[index] ?? '' + if (bodyLine.trim() === eofMarker) { + state.current.isEndOfFile = true + state.current.touched = true + index += 1 + continue + } + if (bodyLine.startsWith('***')) break + if (bodyLine.startsWith('@@')) { + flushChunk(state) + state.current.context = bodyLine.slice(2).trim() + state.current.touched = true + index += 1 + continue + } + feedChunkLine(state, bodyLine) + index += 1 + } + flushChunk(state) + + return { op: PatchOp.update({ path, movePath, chunks: state.chunks }), nextIndex: index } +} + const parseV4A = (lines: ReadonlyArray): Effect.Effect, PatchParseError> => Effect.gen(function* () { const beginIndex = lines.findIndex((line) => line.trim() === beginMarker) @@ -188,15 +239,9 @@ const parseV4A = (lines: ReadonlyArray): Effect.Effect = [] - index += 1 - while (index < endIndex && !(lines[index] ?? '').startsWith('***')) { - const contentLine = lines[index] ?? '' - if (contentLine.startsWith('+')) content.push(contentLine.slice(1)) - index += 1 - } - ops.push(PatchOp.add({ path, content: content.join('\n') })) + const parsed = parseV4AAdd(lines, index, endIndex) + ops.push(parsed.op) + index = parsed.nextIndex continue } @@ -207,38 +252,9 @@ const parseV4A = (lines: ReadonlyArray): Effect.Effect => { + const header = line.slice('diff --git '.length).match(/^a\/(\S+)\s+b\/(\S+)\s*$/) + if (header === null) { + return Effect.fail(new PatchParseError({ message: `invalid git diff header: ${line}` })) + } + return Effect.succeed({ + fromPath: header[1] ?? null, + toPath: header[2] ?? null, + renameFrom: null, + renameTo: null, + sawHeader: false, + chunks: { chunks: [], current: emptyChunk() }, + }) +} + +/** Apply a `--- ` header, opening a new file when no `diff --git` line preceded it. */ +const withOldFileHeader = (file: GitFileState | null, path: string | null): GitFileState => { + if (file === null) { + return { + fromPath: path, + toPath: null, + renameFrom: null, + renameTo: null, + sawHeader: true, + chunks: { chunks: [], current: emptyChunk() }, + } + } + file.fromPath = path + file.sawHeader = true + return file +} + +const startGitHunk = (state: ChunkParseState, hunk: RegExpMatchArray): void => { + flushChunk(state) + state.current.context = hunk[1]?.trim() ?? '' + state.current.touched = true +} + const parseGitDiff = (lines: ReadonlyArray): Effect.Effect, PatchParseError> => Effect.gen(function* () { const ops: Array = [] @@ -323,7 +377,7 @@ const parseGitDiff = (lines: ReadonlyArray): Effect.Effect): Effect.Effect): Effect.Effect): Effect.Effect): Effect.Effect line.trim() === beginMarker)) { const ops = yield* parseV4A(lines) - if (ops.length === 0) return yield* new PatchParseError({ message: 'patch rejected: empty patch' }) + if (Arr.isReadonlyArrayEmpty(ops)) + return yield* new PatchParseError({ message: 'patch rejected: empty patch' }) return ops } @@ -446,7 +475,7 @@ export const applyChunks = (input: { // A trailing newline parses as a trailing empty line; drop it for matching (references), and the // exactly-one-trailing-newline rule below restores it. - if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + if (Arr.isArrayNonEmpty(lines) && lines[lines.length - 1] === '') lines.pop() type Replacement = { readonly index: number @@ -468,7 +497,7 @@ export const applyChunks = (input: { lineIndex = contextIndex + 1 } - if (chunk.oldLines.length === 0) { + if (Arr.isReadonlyArrayEmpty(chunk.oldLines)) { // Pure insertion: anchored at end of file (all three reference implementations). replacements.push({ index: lines.length, deleteCount: 0, newLines: chunk.newLines }) continue @@ -482,7 +511,8 @@ export const applyChunks = (input: { if (matchIndex === -1 && oldLines[oldLines.length - 1] === '') { oldLines = oldLines.slice(0, -1) if (newLines[newLines.length - 1] === '') newLines = newLines.slice(0, -1) - if (oldLines.length > 0) matchIndex = seekSequence(lines, oldLines, lineIndex, chunk.isEndOfFile) + if (Arr.isReadonlyArrayNonEmpty(oldLines)) + matchIndex = seekSequence(lines, oldLines, lineIndex, chunk.isEndOfFile) } if (matchIndex === -1) { diff --git a/packages/fold-core/src/Tools/Truncation.ts b/packages/fold-core/src/Tools/Truncation.ts index 47d37b8..318f6b4 100644 --- a/packages/fold-core/src/Tools/Truncation.ts +++ b/packages/fold-core/src/Tools/Truncation.ts @@ -7,6 +7,7 @@ * one). Truncation happens exactly once, at result creation; the truncated text is then immortal in * log and prompt (the prefix-stability rule). */ +import { Array as Arr } from 'effect' /** Default line limit shared by read and bash truncation (pi parity). */ export const defaultMaxLines = 2000 @@ -83,7 +84,7 @@ export const truncateHead = (text: string, options?: TruncateOptions): Truncatio } } - if (lines.length > 0 && utf8ByteLength(lines[0] ?? '') > maxBytes) { + if (Arr.isReadonlyArrayNonEmpty(lines) && utf8ByteLength(lines[0]) > maxBytes) { return { content: '', truncated: true, @@ -105,7 +106,7 @@ export const truncateHead = (text: string, options?: TruncateOptions): Truncatio break } - const lineBytes = utf8ByteLength(line) + (kept.length > 0 ? 1 : 0) + const lineBytes = utf8ByteLength(line) + (Arr.isArrayNonEmpty(kept) ? 1 : 0) if (bytes + lineBytes > maxBytes) { truncatedBy = 'bytes' break @@ -184,7 +185,7 @@ export const truncateTail = (text: string, options?: TruncateOptions): Truncatio } const line = lines[index] ?? '' - const lineBytes = utf8ByteLength(line) + (kept.length > 0 ? 1 : 0) + const lineBytes = utf8ByteLength(line) + (Arr.isArrayNonEmpty(kept) ? 1 : 0) if (bytes + lineBytes > maxBytes) { truncatedBy = 'bytes' break diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts index f186b49..4fe6107 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Array as Arr, Effect } from 'effect' import { AgentRuntime } from '../../src/index' import { makeScriptedLanguageModel, textTurn } from '../TestLayers/ScriptedLanguageModel' @@ -15,7 +15,7 @@ const withoutCacheControl = (value: unknown): unknown => { if (key === 'cacheControl') continue const normalized = withoutCacheControl(nested) if (key === 'anthropic' && typeof normalized === 'object' && normalized !== null) { - if (Object.keys(normalized).length === 0) continue + if (Arr.isArrayEmpty(Object.keys(normalized))) continue } out[key] = normalized } diff --git a/packages/fold-core/test/TestLayers/TestStopController.ts b/packages/fold-core/test/TestLayers/TestStopController.ts index 9483a09..885d3dd 100644 --- a/packages/fold-core/test/TestLayers/TestStopController.ts +++ b/packages/fold-core/test/TestLayers/TestStopController.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Ref } from 'effect' +import { Array as Arr, Effect, Layer, Ref } from 'effect' import { StopController, type StopControllerService } from '../../src/index' @@ -29,7 +29,7 @@ export const makeRecordingStopController: Effect.Effect /** Record one stop request reason. */ requestStop: (reason) => Ref.update(requests, (reasons) => [...reasons, reason]), /** Report whether any stop request has been recorded. */ - isStopRequested: Ref.get(requests).pipe(Effect.map((reasons) => reasons.length > 0)), + isStopRequested: Ref.get(requests).pipe(Effect.map((reasons) => Arr.isReadonlyArrayNonEmpty(reasons))), }, } }) diff --git a/tools/oxlint/README.md b/tools/oxlint/README.md index f9f461f..297a5a2 100644 --- a/tools/oxlint/README.md +++ b/tools/oxlint/README.md @@ -1,9 +1,61 @@ -# Vendored OXLint rules +# Vendored lint tooling -This directory contains the selected rules that Fold owns and maintains: +Fold owns and maintains the tooling under `tools/`. It is formatted with the repository's oxfmt settings but is +excluded from lint. -- `anti-slop` rules are adapted from . -- `automation` rules are adapted from . +| Path | Source | Run by | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `tools/oxlint/anti-slop` | [K-Mistele/anti-slop](https://github.com/K-Mistele/anti-slop) `src/` at `3abfdfb` (fork of dmmulroy/anti-slop), plus the local `no-conditional-empty-{object,array}-spread` rules | `bun run lint` | +| `tools/oxlint/automation` | [typeonce-dev/ai-automation](https://github.com/typeonce-dev/ai-automation/tree/0bca096fe6fe9878cd15303a623dd2cd85915ddd/rules/oxlint/src) via humanlayer/channels `e499a74` | `bun run lint` | +| `tools/oxlint/import-extensions.mjs` | humanlayer/channels `e499a74` | `bun run lint` | +| `tools/typed` | typeonce-dev/ai-automation `0bca096` via humanlayer/channels `e499a74` | `bun run lint` | -Only rules enabled in `.oxlintrc.jsonc` are vendored. Keep `@oxlint/plugins` aligned with the repository's -`oxlint` version when upgrading either package. +The `automation` rules that Fold used before this import (`no-ambient-nondeterminism`, `no-disable-validation`, +`no-manual-tag-comparison`, `no-manual-tagged-construction`, `no-service-option`, +`no-shadowed-standard-array-static`, `no-silent-error-swallow`, `prefer-effect-match`, +`prefer-option-from-nullable`, `prefer-tagged-error-handling`) are Fold adaptations; the rest are upstream +source. `tools/typed/src/engine.ts` sets `followSymbolicLinks: false` so workspace symlink cycles in +`node_modules` do not break source exclusion. + +Keep `oxlint`, `@oxlint/plugins`, `oxlint-tsgolint`, and `@effect/tsgo` on versions `@effect/tsgo` lists as +supported; `prepare` patches oxlint with the Effect rules used by the `recommended` preset. + +## Rules not yet enabled + +These rules are vendored but off because Fold code still violates them. Counts exclude TUI code, which keeps +its exemption in `.oxlintrc.jsonc`. + +| Rule | Production | Tests | +| ----------------------------------------------------- | ---------- | ----- | +| `automation/private-function-prefix` | 460 | 153 | +| `automation/no-single-use-private-functions` | 327 | 42 | +| `automation/no-multiple-function-params` | 294 | 54 | +| `automation/no-optional-function-parameters` | 100 | 21 | +| `anti-slop/no-known-value-widening` | 95 | 10 | +| `anti-slop/no-runtime-typeof` | 68 | 11 | +| `automation/no-global-json` | 50 | 150 | +| `anti-slop-effect/no-manual-tagged-construction` | 46 | 295 | +| `anti-slop/no-unknown-parameters` | 44 | 22 | +| `anti-slop/no-conditional-spread` | 37 | 3 | +| `anti-slop/no-unsafe-dictionary-type` | 33 | 4 | +| `anti-slop/no-conditional-empty-array-spread` | 26 | 3 | +| `automation/no-try-catch` | 25 | 1 | +| `anti-slop-effect/no-service-constructor-imports` | 22 | 8 | +| `automation/no-typeof-object` | 18 | 5 | +| `automation/no-effect-asvoid` | 15 | 1 | +| `anti-slop/no-reprovide-ambient-service` | 13 | 0 | +| `automation/no-in-operator` | 12 | 6 | +| `automation/no-switch` | 12 | 0 | +| `anti-slop/no-unknown-returns` | 9 | 1 | +| `automation/no-reexport-only-modules` | 7 | 0 | +| `automation/no-type-assertion` | 5 | 5 | +| `automation/no-direct-fetch` | 5 | 0 | +| `anti-slop/no-shape-in-symbol-names` | 5 | 0 | +| `anti-slop/no-reflect-get` | 4 | 2 | +| `automation/no-banned-type-assertions` | 1 | 3 | +| `anti-slop/no-chained-type-assertions` | 1 | 1 | +| `anti-slop/max-ternary-depth` | 1 | 0 | +| `anti-slop/require-safety-comment-for-type-assertion` | 0 | 5 | + +`anti-slop/no-comments` and `automation/no-comments` are intentionally off. The React, XState, Tailwind, and +API-layer `automation` rules do not apply to Fold. diff --git a/tools/oxlint/anti-slop/effect/index.ts b/tools/oxlint/anti-slop/effect/index.ts new file mode 100644 index 0000000..baa9e84 --- /dev/null +++ b/tools/oxlint/anti-slop/effect/index.ts @@ -0,0 +1,21 @@ +import { eslintCompatPlugin } from '@oxlint/plugins' + +import { noManualEffectErrorTagRule } from './rules/no-manual-effect-error-tag.ts' +import { noManualTagComparisonRule } from './rules/no-manual-tag-comparison.ts' +import { noManualTaggedConstructionRule } from './rules/no-manual-tagged-construction.ts' +import { noServiceConstructorImportsRule } from './rules/no-service-constructor-imports.ts' +import { preferEffectMatchRule } from './rules/prefer-effect-match.ts' + +/** Opt-in Oxlint rules for Effect service and Layer architecture. */ +const antiSlopEffectPlugin = eslintCompatPlugin({ + meta: { name: 'anti-slop-effect' }, + rules: { + 'no-manual-effect-error-tag': noManualEffectErrorTagRule, + 'no-manual-tag-comparison': noManualTagComparisonRule, + 'no-manual-tagged-construction': noManualTaggedConstructionRule, + 'no-service-constructor-imports': noServiceConstructorImportsRule, + 'prefer-effect-match': preferEffectMatchRule, + }, +}) + +export default antiSlopEffectPlugin diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts new file mode 100644 index 0000000..48285ac --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts @@ -0,0 +1,44 @@ +import { defineRule } from '@oxlint/plugins' + +import { + isInsideBroadEffectHandler, + isReasonTagMember, + isTagMember, + tagMemberFromComparison, +} from '../shared/tagged-values.ts' + +export const noManualEffectErrorTagRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.', + }, + messages: { + tag: 'Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.', + reason: 'Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.', + }, + }, + createOnce(context) { + return { + BinaryExpression(node) { + const tagMember = tagMemberFromComparison(node) + if (tagMember === undefined || !isInsideBroadEffectHandler(node)) { + return + } + context.report({ + node, + messageId: isReasonTagMember(tagMember) ? 'reason' : 'tag', + }) + }, + SwitchStatement(node) { + if (!isTagMember(node.discriminant) || !isInsideBroadEffectHandler(node)) { + return + } + context.report({ + node, + messageId: isReasonTagMember(node.discriminant) ? 'reason' : 'tag', + }) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts new file mode 100644 index 0000000..66bff8c --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts @@ -0,0 +1,34 @@ +import { defineRule } from '@oxlint/plugins' + +import { isInsideBroadEffectHandler, isTagMember, tagMemberFromComparison } from '../shared/tagged-values.ts' + +export const noManualTagComparisonRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Effect Match or Predicate helpers instead of manually branching on `_tag`.', + }, + messages: { + manualComparison: + 'Use Match.tag/Match.tags for tagged-value branching, or Predicate.isTagged for a simple reusable predicate.', + manualSwitch: + 'Use Match.value(value).pipe(Match.tag/Match.tags/Match.tagsExhaustive) or the tagged enum `$match` helper instead of switching on `_tag`.', + }, + }, + createOnce(context) { + return { + BinaryExpression(node) { + if (tagMemberFromComparison(node) === undefined || isInsideBroadEffectHandler(node)) { + return + } + context.report({ node, messageId: 'manualComparison' }) + }, + SwitchStatement(node) { + if (!isTagMember(node.discriminant) || isInsideBroadEffectHandler(node)) { + return + } + context.report({ node, messageId: 'manualSwitch' }) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts new file mode 100644 index 0000000..5fd1994 --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts @@ -0,0 +1,47 @@ +import { defineRule, type ESTree } from '@oxlint/plugins' + +import { propertyName } from '../shared/tagged-values.ts' + +const isTagProperty = ( + property: ESTree.ObjectProperty | ESTree.PropertyDefinition | ESTree.TSPropertySignature, +): boolean => + (property.key.type === 'Identifier' && property.key.name === '_tag') || + (property.key.type === 'Literal' && property.key.value === '_tag') + +export const noManualTaggedConstructionRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Construct tagged values with their existing Effect constructor instead of writing `_tag` manually.', + }, + messages: { + manualConstruction: + 'Use the existing Schema tagged `.make` or tagged class/error constructor instead of writing a literal `_tag` object.', + manualDeclaration: + 'Declare tagged values with Schema.TaggedStruct, Schema.TaggedClass, Schema.TaggedError, or Schema.TaggedUnion instead of writing a literal `_tag` property.', + }, + }, + createOnce(context) { + return { + ObjectExpression(node) { + const tag = node.properties.find( + (property) => property.type === 'Property' && propertyName(property) === '_tag', + ) + if (tag !== undefined) { + context.report({ node: tag, messageId: 'manualConstruction' }) + } + }, + PropertyDefinition(node) { + if (isTagProperty(node)) { + context.report({ node, messageId: 'manualDeclaration' }) + } + }, + TSPropertySignature(node) { + if (isTagProperty(node)) { + context.report({ node, messageId: 'manualDeclaration' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts new file mode 100644 index 0000000..6534bea --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts @@ -0,0 +1,50 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u + +function isProjectLocalImport(source: string): boolean { + return source.startsWith('./') || source.startsWith('../') +} + +function getImportedName(specifier: ESTree.ImportSpecifier): string { + if (specifier.imported.type === 'Identifier') return specifier.imported.name + return specifier.imported.value +} + +/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */ +export const noServiceConstructorImportsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow project-local make imports outside test and spec files.', + }, + messages: { + serviceConstructorImport: + 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.', + }, + }, + create(context) { + const isTestFile = TEST_FILE.test(context.filename.replaceAll('\\', '/')) + + return { + ImportDeclaration(node) { + if (isTestFile || !isProjectLocalImport(node.source.value)) return + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier') continue + + const importedName = getImportedName(specifier) + if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue + + context.report({ + node: specifier, + messageId: 'serviceConstructorImport', + data: { name: importedName }, + }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts b/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts new file mode 100644 index 0000000..7c6977a --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts @@ -0,0 +1,48 @@ +import { defineRule, type ESTree } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) + +export const preferEffectMatchRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Match from Effect for chained literal ternaries over the same value.', + }, + messages: { + preferMatch: 'Use Match from Effect instead of a chained literal ternary.', + }, + }, + createOnce(context) { + const isLiteral = (node: ESTree.Node): boolean => + node.type === 'Literal' || (node.type === 'TemplateLiteral' && node.expressions.length === 0) + + const comparedValue = (node: ESTree.Expression): string | undefined => { + if (node.type !== 'BinaryExpression' || !equalityOperators.has(node.operator)) { + return undefined + } + if (isLiteral(node.left)) return context.sourceCode.getText(node.right) + if (isLiteral(node.right)) return context.sourceCode.getText(node.left) + return undefined + } + + return { + ConditionalExpression(node) { + if (node.parent?.type === 'ConditionalExpression') return + const value = comparedValue(node.test) + if (value === undefined) return + + let alternate = node.alternate + let literalChecks = 1 + while (alternate.type === 'ConditionalExpression') { + if (comparedValue(alternate.test) !== value) return + literalChecks += 1 + alternate = alternate.alternate + } + + if (literalChecks > 1) { + context.report({ node, messageId: 'preferMatch' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/shared/tagged-values.ts b/tools/oxlint/anti-slop/effect/shared/tagged-values.ts new file mode 100644 index 0000000..4909bda --- /dev/null +++ b/tools/oxlint/anti-slop/effect/shared/tagged-values.ts @@ -0,0 +1,54 @@ +import type { ESTree } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) +const broadEffectCatchMethods = new Set(['catch', 'catchAll', 'catchIf']) + +export const isStringLiteral = (node: ESTree.Node | null | undefined): node is ESTree.StringLiteral => + node?.type === 'Literal' && typeof node.value === 'string' + +export const isTagMember = (node: ESTree.Node | null | undefined): node is ESTree.MemberExpression => + node?.type === 'MemberExpression' && + ((!node.computed && node.property.type === 'Identifier' && node.property.name === '_tag') || + (node.computed && isStringLiteral(node.property) && node.property.value === '_tag')) + +export const tagMemberFromComparison = (node: ESTree.BinaryExpression): ESTree.MemberExpression | undefined => { + if (!equalityOperators.has(node.operator)) return undefined + if (isTagMember(node.left) && isStringLiteral(node.right)) return node.left + if (isTagMember(node.right) && isStringLiteral(node.left)) return node.right + return undefined +} + +const isBroadEffectCatchCall = (node: ESTree.Node | null | undefined): node is ESTree.CallExpression => + node?.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Effect' && + !node.callee.computed && + node.callee.property.type === 'Identifier' && + broadEffectCatchMethods.has(node.callee.property.name) + +export const isInsideBroadEffectHandler = (node: ESTree.Node): boolean => { + let current: ESTree.Node | null | undefined = node.parent + while (current !== null && current !== undefined) { + if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') { + return isBroadEffectCatchCall(current.parent) && current.parent.arguments.includes(current) + } + current = current.parent + } + return false +} + +export const isReasonTagMember = (node: ESTree.MemberExpression): boolean => + node.object.type === 'MemberExpression' && + ((!node.object.computed && node.object.property.type === 'Identifier' && node.object.property.name === 'reason') || + (node.object.computed && isStringLiteral(node.object.property) && node.object.property.value === 'reason')) + +export const propertyName = (property: ESTree.ObjectProperty): string | undefined => { + if (!property.computed && property.key.type === 'Identifier') { + return property.key.name + } + if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + return property.key.value + } + return undefined +} diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 3917980..2796f07 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -1,16 +1,51 @@ import { eslintCompatPlugin } from '@oxlint/plugins' +import { maxTernaryDepthRule } from './rules/max-ternary-depth.ts' +import { noChainedTypeAssertionsRule } from './rules/no-chained-type-assertions.ts' +import { noCommentsRule } from './rules/no-comments.ts' +import { noConditionalEmptyArraySpreadRule } from './rules/no-conditional-empty-array-spread.ts' import { noConditionalEmptyObjectSpreadRule } from './rules/no-conditional-empty-object-spread.ts' +import { noConditionalSpreadRule } from './rules/no-conditional-spread.ts' +import { noKnownValueWideningRule } from './rules/no-known-value-widening.ts' import { noModuleMockingRule } from './rules/no-module-mocking.ts' import { noObjectParametersRule } from './rules/no-object-parameters.ts' import { noReflectApplyRule } from './rules/no-reflect-apply.ts' +import { noReflectGetRule } from './rules/no-reflect-get.ts' +import { noReprovideAmbientServiceRule } from './rules/no-reprovide-ambient-service.ts' +import { noRuntimeTypeofRule } from './rules/no-runtime-typeof.ts' +import { noForbiddenTermInSymbolNamesRule } from './rules/no-shape-in-symbol-names.ts' +import { noUnknownParametersRule } from './rules/no-unknown-parameters.ts' +import { noUnknownReturnsRule } from './rules/no-unknown-returns.ts' +import { noUnknownTypeAliasesRule } from './rules/no-unknown-type-aliases.ts' +import { noUnsafeDictionaryTypeRule } from './rules/no-unsafe-dictionary-type.ts' +import { noWidenThenAssertRule } from './rules/no-widen-then-assert.ts' +import { requireSafetyCommentForTypeAssertionRule } from './rules/require-safety-comment-for-type-assertion.ts' -export default eslintCompatPlugin({ +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ meta: { name: 'anti-slop' }, rules: { + 'max-ternary-depth': maxTernaryDepthRule, + 'no-chained-type-assertions': noChainedTypeAssertionsRule, + 'no-comments': noCommentsRule, + 'no-conditional-empty-array-spread': noConditionalEmptyArraySpreadRule, 'no-conditional-empty-object-spread': noConditionalEmptyObjectSpreadRule, + 'no-conditional-spread': noConditionalSpreadRule, + 'no-known-value-widening': noKnownValueWideningRule, 'no-module-mocking': noModuleMockingRule, 'no-object-parameters': noObjectParametersRule, 'no-reflect-apply': noReflectApplyRule, + 'no-reflect-get': noReflectGetRule, + 'no-reprovide-ambient-service': noReprovideAmbientServiceRule, + 'no-runtime-typeof': noRuntimeTypeofRule, + 'no-unsafe-dictionary-type': noUnsafeDictionaryTypeRule, + 'no-shape-in-symbol-names': noForbiddenTermInSymbolNamesRule, + 'no-unknown-parameters': noUnknownParametersRule, + 'no-unknown-returns': noUnknownReturnsRule, + 'no-unknown-type-aliases': noUnknownTypeAliasesRule, + 'no-widen-then-assert': noWidenThenAssertRule, + 'require-safety-comment-for-type-assertion': requireSafetyCommentForTypeAssertionRule, }, }) + +export default antiSlopPlugin diff --git a/tools/oxlint/anti-slop/rules/max-ternary-depth.ts b/tools/oxlint/anti-slop/rules/max-ternary-depth.ts new file mode 100644 index 0000000..06029bb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/max-ternary-depth.ts @@ -0,0 +1,80 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapExpression(node: ESTree.Expression): ESTree.Expression { + let current = node + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current +} + +function ternaryDepth(node: ESTree.Expression): number { + const expression = unwrapExpression(node) + if (expression.type !== 'ConditionalExpression') return 0 + return ( + 1 + + Math.max(ternaryDepth(expression.test), ternaryDepth(expression.consequent), ternaryDepth(expression.alternate)) + ) +} + +function isNestedTernary(node: ESTree.ConditionalExpression): boolean { + let parent = node.parent + while ( + parent.type === 'ParenthesizedExpression' || + parent.type === 'TSAsExpression' || + parent.type === 'TSSatisfiesExpression' || + parent.type === 'TSTypeAssertion' || + parent.type === 'TSNonNullExpression' + ) { + parent = parent.parent + } + return parent.type === 'ConditionalExpression' +} + +/** Limit ternary chains while preserving concise two-way selections. */ +export const maxTernaryDepthRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Limit ternary expression chains to a configurable depth.', + }, + messages: { + exceeded: + 'This ternary has a depth of {{depth}}; the configured maximum is {{maxDepth}}. Use a lookup table, switch, or named decision function.', + }, + schema: [ + { + type: 'object', + properties: { maxDepth: { type: 'integer', minimum: 1 } }, + additionalProperties: false, + }, + ], + defaultOptions: [{ maxDepth: 2 }], + }, + createOnce(context) { + return { + ConditionalExpression(node) { + if (isNestedTernary(node)) return + const option = context.options?.[0] + const maxDepth = + typeof option === 'object' && + option !== null && + !Array.isArray(option) && + typeof option.maxDepth === 'number' + ? option.maxDepth + : 2 + const depth = ternaryDepth(node) + if (depth > maxDepth) { + context.report({ node, messageId: 'exceeded', data: { depth, maxDepth } }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..e97ad90 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,76 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion' +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node + return ( + typeAnnotation.type === 'TSTypeReference' && + typeAnnotation.typeName.type === 'Identifier' && + typeAnnotation.typeName.name === 'const' + ) +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node + let parent = node.parent + + while (parent.type === 'ParenthesizedExpression' && parent.expression === current) { + current = parent + parent = parent.parent + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0 + let hasNonConstAssertion = false + let current: ESTree.Expression = node + + while (isTypeAssertionExpression(current)) { + assertionCount += 1 + hasNonConstAssertion ||= !isConstAssertion(current) + current = unwrapParenthesizedExpression(current.expression) + } + + return assertionCount > 1 && hasNonConstAssertion +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.', + }, + messages: { + chained: + 'This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.', + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return + context.report({ node, messageId: 'chained' }) + } + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-comments.ts b/tools/oxlint/anti-slop/rules/no-comments.ts new file mode 100644 index 0000000..2b04aa7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-comments.ts @@ -0,0 +1,25 @@ +import { defineRule } from '@oxlint/plugins' + +/** Reject implementation comments except SAFETY justifications required by assertion policy. */ +export const noCommentsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Reject implementation comments; code should communicate intent directly. SAFETY justifications remain allowed.', + }, + messages: { + comment: 'Remove this comment and make the code communicate its intent directly.', + }, + }, + createOnce(context) { + return { + Program() { + for (const comment of context.sourceCode.getAllComments()) { + if (/\bSAFETY\s*:/u.test(comment.value)) continue + context.report({ node: comment, messageId: 'comment' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts new file mode 100644 index 0000000..cd2e187 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts @@ -0,0 +1,44 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isEmptyArrayExpression(node: ESTree.Expression): boolean { + return node.type === 'ArrayExpression' && node.elements.length === 0 +} + +function isConditionalEmptyArraySpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node) + return ( + conditional.type === 'ConditionalExpression' && + (isEmptyArrayExpression(conditional.consequent) || isEmptyArrayExpression(conditional.alternate)) + ) +} + +export const noConditionalEmptyArraySpreadRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow array spreads that conditionally spread an empty array to omit items.', + }, + messages: { + avoid: 'This conditional spread hides item omission behind an empty array. Build the array in separate statements and add the item only when present.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== 'ArrayExpression') return + if (isConditionalEmptyArraySpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-conditional-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-spread.ts new file mode 100644 index 0000000..9e4a2d5 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-spread.ts @@ -0,0 +1,42 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isConditionalSpread(node: ESTree.Expression): boolean { + const expression = unwrapParentheses(node) + return expression.type === 'ConditionalExpression' || expression.type === 'LogicalExpression' +} + +/** Ban spreading ternary and logical expressions in every spread position. */ +export const noConditionalSpreadRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow spreading ternary and logical expressions. Build the value explicitly instead.', + }, + messages: { + avoid: 'Conditional spreads obscure which values or properties are present. Build the value in separate statements instead.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (isConditionalSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + JSXSpreadAttribute(node) { + if (isConditionalSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..58d441e --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,217 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from '../shared/dictionary-types.ts' + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current +} + +function resolveVariable(sourceCode: SourceCode, identifier: ESTree.IdentifierReference): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null + const [definition] = variable.defs + return definition?.type === 'Variable' && definition.node.type === 'VariableDeclarator' ? definition.node : null +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === 'VariableDeclaration' && + declarator.parent.kind === 'const' && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ) +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true + const unwrapped = unwrapExpression(expression) + if (unwrapped.type !== 'Identifier') return false + const variable = resolveVariable(sourceCode, unwrapped) + if (variable === null || visitedVariables.has(variable)) return false + const declarator = variableDeclarator(variable) + if (declarator === null || declarator.init === null || !isStableConstVariable(variable, declarator)) { + return false + } + visitedVariables.add(variable) + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables) +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment) +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if ( + current.type === 'ArrowFunctionExpression' || + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' + ) { + return current + } + current = current.parent + } + return null +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === 'Identifier' || key.type === 'PrivateIdentifier') return key.name + if (key.type === 'Literal') return String(key.value) + return sourceCode.getText(key) +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return 'anonymous function' + if (owner.id !== null) return owner.id.name + const parent = owner.parent + if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') return parent.id.name + if (parent.type === 'MethodDefinition') return sourceKeyName(sourceCode, parent.key) + return 'anonymous function' +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression) + return unwrapped.type === 'ObjectExpression' && unwrapped.properties.length === 0 +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === 'open dictionary' || destination.kind === 'generic container' +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === 'TSAsExpression' || node.parent?.type === 'TSTypeAssertion' +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.', + }, + messages: { + widening: + 'The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.', + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null + + const reportFlow = (expression: ESTree.Expression, destination: WideningTarget | null, subject: string) => { + if (destination === null) return + if (isDictionaryAccumulatorTarget(destination) && isEmptyObjectExpression(expression)) { + return + } + if (!hasKnownEvidence(context.sourceCode, expression)) return + context.report({ + node: expression, + messageId: 'widening', + data: { subject, target: destination.kind }, + }) + } + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment) + + return { + Program(node) { + environment = createTypeEnvironment(node) + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== 'Identifier') return + reportFlow(node.init, targetFromAnnotation(node.id.typeAnnotation), `binding \`${node.id.name}\``) + }, + PropertyDefinition(node) { + if (node.value === null) return + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ) + }, + AccessorProperty(node) { + if (node.value === null) return + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ) + }, + AssignmentExpression(node) { + if (node.operator !== '=' || node.left.type !== 'Identifier') return + const variable = resolveVariable(context.sourceCode, node.left) + if (variable === null) return + const declarator = variableDeclarator(variable) + if (declarator === null || declarator.id.type !== 'Identifier') return + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ) + }, + ReturnStatement(node) { + if (node.argument === null) return + const owner = enclosingFunction(node) + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ) + }, + ArrowFunctionExpression(node) { + if (node.body.type === 'BlockStatement') return + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ) + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return + reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), 'assertion') + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return + reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), 'assertion') + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts index 1e2951f..c211955 100644 --- a/tools/oxlint/anti-slop/rules/no-module-mocking.ts +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -56,6 +56,7 @@ function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): bool return method !== null && moduleMockMethods.has(method) } +/** Ban test framework module mocking in favor of real dependency seams. */ export const noModuleMockingRule = defineRule({ meta: { type: 'problem', diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts index e774b4e..b2217cc 100644 --- a/tools/oxlint/anti-slop/rules/no-object-parameters.ts +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -14,9 +14,15 @@ type ParameterOwner = | ESTree.TSMethodSignature function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { - if (parameter.type === 'TSParameterProperty') return parameterAnnotation(parameter.parameter) - if (parameter.type === 'RestElement') return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) - if (parameter.type === 'AssignmentPattern') return parameter.typeAnnotation ?? parameter.left.typeAnnotation + if (parameter.type === 'TSParameterProperty') { + return parameterAnnotation(parameter.parameter) + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation + } return parameter.typeAnnotation } @@ -26,11 +32,13 @@ function parameterName(parameter: Parameter, sourceCode: SourceCode): string { : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, '') } +/** Ban the broad object type on function inputs, including local aliases to object. */ export const noObjectParametersRule = defineRule({ meta: { type: 'problem', docs: { - description: 'Disallow object function parameters; inputs must use an owner-provided type.', + description: + 'Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.', }, messages: { objectParameter: diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts index 8bcba03..46ae36c 100644 --- a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -2,6 +2,7 @@ import { defineRule } from '@oxlint/plugins' import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts' +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ export const noReflectApplyRule = defineRule({ meta: { type: 'problem', diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000..ac357d3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,27 @@ +import { defineRule } from '@oxlint/plugins' + +import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts' + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.', + }, + messages: { + reflectGet: + 'Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, 'get')) { + context.report({ node, messageId: 'reflectGet' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts b/tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts new file mode 100644 index 0000000..ee2a026 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts @@ -0,0 +1,123 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +function resolveVariable(sourceCode: SourceCode, identifier: ESTree.IdentifierReference): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null + const [definition] = variable.defs + return definition?.type === 'Variable' && definition.node.type === 'VariableDeclarator' ? definition.node : null +} + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current +} + +function importedName(specifier: ESTree.ImportSpecifier): string { + return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value +} + +function isEffectProvideServiceCall(node: ESTree.CallExpression, effectNamespaces: ReadonlySet): boolean { + const callee = unwrapExpression(node.callee) + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + effectNamespaces.has(callee.object.name) && + callee.property.type === 'Identifier' && + callee.property.name === 'provideService' + ) +} + +function directlyYieldedService(sourceCode: SourceCode, expression: ESTree.Expression): ESTree.Expression | null { + const value = unwrapExpression(expression) + if (value.type !== 'Identifier') return null + const variable = resolveVariable(sourceCode, value) + if (variable === null) return null + const declarator = variableDeclarator(variable) + if (declarator?.init === null || declarator?.init === undefined) return null + const initializer = unwrapExpression(declarator.init) + return initializer.type === 'YieldExpression' && initializer.delegate && initializer.argument !== null + ? unwrapExpression(initializer.argument) + : null +} + +/** Keep ambient Effect dependencies in R instead of capturing and re-providing them operation by operation. */ +export const noReprovideAmbientServiceRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow re-providing an Effect service that was yielded directly from the surrounding environment.', + }, + messages: { + reprovideAmbientService: + 'Do not capture "{{service}}" from the Effect environment and re-provide it with Effect.provideService. Leave "{{service}}" in the operation\'s R channel and provide its implementing Layer once at the application runtime composition root. If the runtime has an explicit service union, include "{{service}}" there; use Layer.provideMerge when the service must remain available after layer construction. A local provide is only appropriate for a genuinely dynamic implementation, such as a client configured from per-tenant credentials.', + }, + }, + createOnce(context) { + const effectNamespaces = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value === 'effect') { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Effect') { + effectNamespaces.add(specifier.local.name) + } + } + return + } + + if (node.source.value !== 'effect/Effect') return + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier' || specifier.type === 'ImportDefaultSpecifier') { + effectNamespaces.add(specifier.local.name) + } + } + }, + CallExpression(node) { + if (!isEffectProvideServiceCall(node, effectNamespaces) || node.arguments.length < 2) return + const serviceArgument = node.arguments.at(-2) + const implementationArgument = node.arguments.at(-1) + if ( + serviceArgument === undefined || + implementationArgument === undefined || + serviceArgument.type === 'SpreadElement' || + implementationArgument.type === 'SpreadElement' + ) { + return + } + + const yieldedService = directlyYieldedService(context.sourceCode, implementationArgument) + if (yieldedService === null) return + const requestedService = unwrapExpression(serviceArgument) + if (context.sourceCode.getText(yieldedService) !== context.sourceCode.getText(requestedService)) return + + context.report({ + node, + messageId: 'reprovideAmbientService', + data: { service: context.sourceCode.getText(requestedService) }, + }) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..3fc6788 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,63 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' + ) +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === 'TSTypePredicate' + } + current = current.parent + } + return false +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.', + }, + messages: { + runtimeTypeof: + 'A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.', + }, + schema: [ + { + type: 'object', + properties: { + allowInTypeGuards: { type: 'boolean' }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0] + const allowInTypeGuards = + typeof option === 'object' && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true + if (node.operator === 'typeof' && (!allowInTypeGuards || !isInsideTypeGuard(node))) { + context.report({ node, messageId: 'runtimeTypeof' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000..7895de0 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const FORBIDDEN_SYMBOL_NAME = 'shape' + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME) +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return + context.report({ + node, + messageId: 'forbiddenSymbolName', + data: { name: node.name }, + }) + } + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 0000000..9616911 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,81 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +type Parameter = ESTree.ParamPattern +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === 'TSParameterProperty') { + return parameterAnnotation(parameter.parameter) + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation + } + return parameter.typeAnnotation +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === 'TSParameterProperty') { + return parameterName(parameter.parameter, sourceText) + } + if (parameter.type === 'AssignmentPattern') { + return parameterName(parameter.left, sourceText) + } + if (parameter.type === 'RestElement') { + return parameterName(parameter.argument, sourceText) + } + return parameter.type === 'Identifier' ? parameter.name : sourceText.replace(/\s*:\s*unknown\s*$/u, '') +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.', + }, + messages: { + unknownParameter: + 'Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.', + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter) + if (annotation?.typeAnnotation.type !== 'TSUnknownKeyword') continue + const name = parameterName(parameter, context.sourceCode.getText(parameter)) + if (name === 'cause') continue + context.report({ + node: annotation.typeAnnotation, + messageId: 'unknownParameter', + data: { parameter: name }, + }) + } + } + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 0000000..2552677 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,105 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +import { lexicalTypeParameterNames } from '../shared/lexical-type-parameters.ts' + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === 'TSParenthesizedType') return referencedAliasName(type.typeAnnotation) + if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return null + return type.typeArguments === null || type.typeArguments === undefined || type.typeArguments.params.length === 0 + ? type.typeName.name + : null +} + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow functions whose explicit return contract is unknown or Promise.', + }, + messages: { + unknownReturn: + 'This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.', + }, + }, + createOnce(context) { + const aliases = new Map() + + const resolvesToUnknown = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === 'TSUnknownKeyword') return true + if (type.type === 'TSParenthesizedType') { + return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited) + } + if (type.type === 'TSUnionType') { + return type.types.some((member) => resolvesToUnknown(member, shadowedAliases, visited)) + } + if ( + type.type === 'TSTypeReference' && + type.typeName.type === 'Identifier' && + (type.typeName.name === 'Promise' || type.typeName.name === 'PromiseLike') + ) { + const value = type.typeArguments?.params[0] + return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited) + } + const name = referencedAliasName(type) + if (name === null || visited.has(name) || shadowedAliases.has(name)) return false + const alias = aliases.get(name) + if (alias === undefined || (alias.typeParameters !== null && alias.typeParameters !== undefined)) { + return false + } + const nextVisited = new Set(visited) + nextVisited.add(name) + return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited) + } + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType + if (annotation === null || annotation === undefined) return + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), + ) + ) { + return + } + context.report({ node: annotation.typeAnnotation, messageId: 'unknownReturn' }) + } + + return { + Program(node) { + aliases.clear() + for (const statement of node.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement + if (declaration?.type === 'TSTypeAliasDeclaration') { + aliases.set(declaration.id.name, declaration) + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000..32a7103 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,62 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === 'TSParenthesizedType') return referencedAliasName(type.typeAnnotation) + if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return null + return type.typeArguments === null || type.typeArguments === undefined || type.typeArguments.params.length === 0 + ? type.typeName.name + : null +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.', + }, + messages: { + unknownAlias: + 'Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.', + }, + }, + createOnce(context) { + const aliases = new Map() + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === 'TSUnknownKeyword') return true + if (type.type === 'TSParenthesizedType') return resolvesToUnknown(type.typeAnnotation, visited) + const name = referencedAliasName(type) + if (name === null || visited.has(name)) return false + const alias = aliases.get(name) + if (alias === undefined || (alias.typeParameters !== null && alias.typeParameters !== undefined)) { + return false + } + const nextVisited = new Set(visited) + nextVisited.add(name) + return resolvesToUnknown(alias.typeAnnotation, nextVisited) + } + + return { + Program(node) { + aliases.clear() + for (const statement of node.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement + if (declaration?.type === 'TSTypeAliasDeclaration') { + aliases.set(declaration.id.name, declaration) + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue + context.report({ + node: alias.id, + messageId: 'unknownAlias', + data: { alias: alias.id.name }, + }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..a521d6e --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,124 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from '../shared/dictionary-types.ts' + +const typeNodeKinds: ReadonlySet = new Set([ + 'JSDocNonNullableType', + 'JSDocNullableType', + 'JSDocUnknownType', + 'TSAnyKeyword', + 'TSArrayType', + 'TSBigIntKeyword', + 'TSBooleanKeyword', + 'TSConditionalType', + 'TSConstructorType', + 'TSFunctionType', + 'TSImportType', + 'TSIndexedAccessType', + 'TSInferType', + 'TSIntersectionType', + 'TSIntrinsicKeyword', + 'TSLiteralType', + 'TSMappedType', + 'TSNamedTupleMember', + 'TSNeverKeyword', + 'TSNullKeyword', + 'TSNumberKeyword', + 'TSObjectKeyword', + 'TSParenthesizedType', + 'TSStringKeyword', + 'TSSymbolKeyword', + 'TSTemplateLiteralType', + 'TSThisType', + 'TSTupleType', + 'TSTypeLiteral', + 'TSTypeOperator', + 'TSTypePredicate', + 'TSTypeQuery', + 'TSTypeReference', + 'TSUndefinedKeyword', + 'TSUnionType', + 'TSUnknownKeyword', + 'TSVoidKeyword', +]) + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type) +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if (current.type === 'TSTypeAliasDeclaration') return true + current = current.parent + } + return false +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== 'TSTypeReference' || node.typeArguments?.params.length) return false + const name = typeReferenceName(node) + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node) +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false + if (classifyUnsafeDictionary(node, environment) === null) return false + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) return false + current = current.parent + } + return true +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.', + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: 'unsafeDictionary', data: { value } }) + } + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return + const unsafe = classifyUnsafeDictionary(node, environment) + if (unsafe === null) return + report(node, unsafe.unsafeValue) + } + + return { + Program(node) { + environment = createTypeEnvironment(node) + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if (environment === null || node.typeAnnotation === null || node.parent.type === 'TSTypeLiteral') return + const unsafe = classifyUnsafeDictionaryValue(node.typeAnnotation.typeAnnotation, environment) + if (unsafe !== null) report(node, unsafe.unsafeValue) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..fa15031 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,343 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Variable } from '@oxlint/plugins' + +type BroadTypeKind = 'top' | 'object' | 'record' + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null +} + +const functionBoundaryTypes = new Set([ + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', + 'TSDeclareFunction', + 'TSEmptyBodyFunctionExpression', +]) + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while (current.type === 'ParenthesizedExpression') current = current.expression + return current +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type + while (current.type === 'TSParenthesizedType') current = current.typeAnnotation + return current +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + return unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword' +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true + } + if (unwrapped.type === 'TSUnionType') return unwrapped.types.every(isBroadRecordKeyType) + return unwrapped.type === 'TSTypeReference' && typeReferenceName(unwrapped) === 'PropertyKey' +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + + if (unwrapped.type === 'TSTypeReference') { + if (typeReferenceName(unwrapped) === 'Readonly') { + const [inner] = unwrapped.typeArguments?.params ?? [] + return inner !== undefined && isBroadRecordType(inner) + } + + if (typeReferenceName(unwrapped) !== 'Record') return false + const parameters = unwrapped.typeArguments?.params ?? [] + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ) + } + + if (unwrapped.type !== 'TSTypeLiteral' || unwrapped.members.length !== 1) return false + const [member] = unwrapped.members + const [parameter] = member?.type === 'TSIndexSignature' ? member.parameters : [] + return ( + member?.type === 'TSIndexSignature' && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ) +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type) + if (unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword') return 'top' + if (unwrapped.type === 'TSObjectKeyword') return 'object' + return isBroadRecordType(unwrapped) ? 'record' : null +} + +function assertedExpression(node: ESTree.TSAsExpression | ESTree.TSTypeAssertion): ESTree.Expression { + return unwrapExpressionParentheses(node.expression) +} + +function assertionFromExpression(expression: ESTree.Expression): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression) + return unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion' ? unwrapped : null +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, '') +} + +function typesHaveSameSyntax(sourceText: string, left: ESTree.TSType | null, right: ESTree.TSType): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ) +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + switch (unwrapped.type) { + case 'TSArrayType': + case 'TSConstructorType': + case 'TSFunctionType': + case 'TSMappedType': + case 'TSObjectKeyword': + case 'TSTupleType': + return true + case 'TSTypeLiteral': + return unwrapped.members.length > 0 + case 'TSIntersectionType': + return unwrapped.types.every(isDefinitelyObjectType) + case 'TSTypeOperator': + return unwrapped.operator === 'readonly' && isDefinitelyObjectType(unwrapped.typeAnnotation) + default: + return false + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type !== 'TSIndexSignature') + } + + if (unwrapped.type !== 'TSTypeReference') return false + if (typeReferenceName(unwrapped) === 'Readonly') { + const [inner] = unwrapped.typeArguments?.params ?? [] + return inner !== undefined && isDefinitelyNarrowerRecordType(inner) + } + if (typeReferenceName(unwrapped) !== 'Record') return false + + const parameters = unwrapped.typeArguments?.params ?? [] + return parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent + while (current !== null && current.type !== 'Program') { + if (functionBoundaryTypes.has(current.type)) return current + current = current.parent + } + return null +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node + readonly resolved: Variable | null + }[] + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && candidate.identifier.end === identifier.end, + ) + if (reference !== undefined) return reference.resolved + } + return null +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === 'Variable' && definition.node.type === 'VariableDeclarator') { + return definition.node + } + } + return null +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression) + + if (unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion') { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null + return { type: unwrapped.typeAnnotation } + } + + if (unwrapped.type === 'Literal' || unwrapped.type === 'TemplateLiteral') { + return { type: null } + } + + if ( + unwrapped.type === 'ArrayExpression' || + unwrapped.type === 'ArrowFunctionExpression' || + unwrapped.type === 'ClassExpression' || + unwrapped.type === 'FunctionExpression' || + unwrapped.type === 'NewExpression' || + unwrapped.type === 'ObjectExpression' + ) { + return { type: null } + } + + if (unwrapped.type !== 'Identifier') return null + const variable = resolvedVariableForIdentifier(scopes, unwrapped) + if (variable === null || visitedVariables.has(variable)) return null + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ) + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null + } + return { type: annotation } + } + + const declarator = variableDeclarator(variable) + if ( + declarator === null || + declarator.parent.type !== 'VariableDeclaration' || + declarator.parent.kind !== 'const' || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null + } + + return knownValueEvidence(declarator.init, scopes, boundary, new Set([...visitedVariables, variable])) +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind + readonly evidence: KnownValueEvidence + readonly declaredAt: number + readonly boundary: ESTree.Node | null +} | null { + const declarator = variableDeclarator(variable) + if ( + declarator === null || + declarator.parent.type !== 'VariableDeclaration' || + declarator.parent.kind !== 'const' || + declarator.id.type !== 'Identifier' || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null + } + + const boundary = functionBoundary(declarator) + const declaredType = declarator.id.typeAnnotation?.typeAnnotation + const initializerAssertion = assertionFromExpression(declarator.init) + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation) + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType) + const broadKind = declaredBroadKind ?? initializerBroadKind + if (broadKind === null) return null + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])) + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary } +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false + if (broadKind === 'top') return true + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true + if (broadKind === 'object') return isDefinitelyObjectType(assertedType) + return isDefinitelyNarrowerRecordType(assertedType) +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.', + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = [] + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node) + if (expression.type !== 'Identifier') return + + const variable = resolvedVariableForIdentifier(scopes, expression) + if (variable === null) return + const widened = widenedBinding(variable, scopes) + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower(context.sourceCode.text, widened.broadKind, widened.evidence, node.typeAnnotation) + ) { + return + } + + context.report({ + node, + messageId: 'widenThenAssert', + data: { name: expression.name }, + }) + } + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..f0338ec --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,60 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, SourceCode } from '@oxlint/plugins' + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion + +const commentOwnerKinds = new Set([ + 'ExpressionStatement', + 'PropertyDefinition', + 'ReturnStatement', + 'ThrowStatement', + 'VariableDeclaration', +]) + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === 'TSTypeReference' && + node.typeAnnotation.typeName.type === 'Identifier' && + node.typeAnnotation.typeName.name === 'const' + ) +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true + } + if (commentOwnerKinds.has(current.type) || current.parent.type === 'Program') return false + current = current.parent + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.', + }, + messages: { + missingSafetyComment: + 'This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.', + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return + context.report({ node, messageId: 'missingSafetyComment' }) + } + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + } + }, +}) diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000..7fcdb37 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,443 @@ +import type { ESTree } from '@oxlint/plugins' + +const BUILT_INS = new Set(['Record', 'Readonly', 'Partial', 'Required', 'Pick', 'Omit', 'PropertyKey', 'NonNullable']) +const TRANSPARENT_WRAPPERS = new Set(['Readonly', 'Partial', 'Required', 'NonNullable']) + +type TypeAliasEnvironment = ReadonlyMap + +type ResolvedType = { + readonly type: ESTree.TSType + readonly substitutions: TypeAliasEnvironment +} + +export type UnsafeDictionary = { + readonly kind: 'unsafe-dictionary' + readonly unsafeValue: 'any' | 'empty-object' | 'object' | 'union' | 'unknown' +} + +export type WideningTargetKind = 'anonymous object' | 'generic container' | 'object' | 'open dictionary' | 'unknown' + +export type WideningTarget = { + readonly kind: WideningTargetKind +} + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap + readonly interfaces: ReadonlyMap + readonly shadowedBuiltIns: ReadonlySet +} + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration' + ? (statement.declaration ?? null) + : statement +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map() + const interfaces = new Map() + const shadowedBuiltIns = new Set() + + for (const statement of program.body) { + const declaration = declaredStatement(statement) + if (declaration?.type === 'ImportDeclaration') { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name) + } + continue + } + + if (declaration?.type === 'TSTypeAliasDeclaration') { + const existing = aliases.get(declaration.id.name) + if (existing === undefined) aliases.set(declaration.id.name, declaration) + else shadowedBuiltIns.add(declaration.id.name) + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + continue + } + + if (declaration?.type === 'TSInterfaceDeclaration') { + const declarations = interfaces.get(declaration.id.name) ?? [] + declarations.push(declaration) + interfaces.set(declaration.id.name, declarations) + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + continue + } + + if (declaration?.type === 'TSEnumDeclaration') { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + continue + } + + if ( + (declaration?.type === 'ClassDeclaration' || declaration?.type === 'FunctionDeclaration') && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + } + } + + return { aliases, interfaces, shadowedBuiltIns } +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name) +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type) + return ( + unwrapped.type === 'TSTypeReference' && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ) +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type + while ( + current.type === 'TSParenthesizedType' || + (current.type === 'TSTypeOperator' && current.operator === 'readonly') + ) { + current = current.typeAnnotation + } + return current +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === 'TSNeverKeyword' +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === 'TSPropertySignature' && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ) +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember) +} + +function isEffectivelyEmptyInterface(declarations: readonly ESTree.TSInterfaceDeclaration[]): boolean { + if (declarations.length !== 1) return false + const [type] = declarations + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ) +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type !== 'TSTypeReference') return type + const name = typeReferenceName(unwrapped) + if (name === null || resolving.has(name)) return type + const substitution = base.get(name) + if (substitution === undefined) return type + const nextResolving = new Set(resolving) + nextResolving.add(name) + return resolvedSubstitutionArgument(substitution, base, nextResolving) +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? [] + const arguments_ = type.typeArguments?.params ?? [] + const next = new Map(base) + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default + if (argument === null || argument === undefined) return null + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)) + } + return next +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary['unsafeValue'] | null { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type === 'TSUnknownKeyword') return 'unknown' + if (unwrapped.type === 'TSAnyKeyword') return 'any' + if (unwrapped.type === 'TSObjectKeyword') return 'object' + if (unwrapped.type === 'TSTypeLiteral' && isEffectivelyEmptyTypeLiteral(unwrapped)) return 'empty-object' + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? 'union' + : null + } + if (unwrapped.type === 'TSIntersectionType') { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ) + if (unsafeMembers.includes('any')) return 'any' + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) ? unsafeMembers[0] : null + } + if (unwrapped.type !== 'TSTypeReference') return null + const name = typeReferenceName(unwrapped) + if (name === null) return null + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? null : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases) + } + const substitution = substitutions.get(name) + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases) + } + const interfaceDeclarations = environment.interfaces.get(name) + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? 'empty-object' : null + } + const alias = environment.aliases.get(name) + if (alias === undefined || resolvingAliases.has(name)) return null + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return null + const nextResolving = new Set(resolvingAliases) + nextResolving.add(name) + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving) +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type) + + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === 'TSIndexSignature' && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ) + } + + if (unwrapped.type === 'TSMappedType') { + return unwrapped.typeAnnotation === null ? [] : [{ type: unwrapped.typeAnnotation, substitutions }] + } + + if (unwrapped.type !== 'TSTypeReference') return [] + const name = typeReferenceName(unwrapped) + if (name === null) return [] + + const substitution = substitutions.get(name) + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases) + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? [] : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases) + } + + if (name === 'Record' && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null + return value === null ? [] : [{ type: value, substitutions }] + } + + if ((name === 'Pick' || name === 'Omit') && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0] + return source === undefined ? [] : dictionaryValueTypes(source, environment, substitutions, resolvingAliases) + } + + const alias = environment.aliases.get(name) + if (alias === undefined || resolvingAliases.has(name)) return [] + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return [] + const nextResolving = new Set(resolvingAliases) + nextResolving.add(name) + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving) +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()) + return unsafeValue === null ? null : { kind: 'unsafe-dictionary', unsafeValue } +} + +export function classifyUnsafeDictionary(type: ESTree.TSType, environment: TypeEnvironment): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue(valueType.type, environment, valueType.substitutions, new Set()) + if (unsafeValue !== null) return { kind: 'unsafe-dictionary', unsafeValue } + } + return null +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0 +} + +export function classifyWideningTarget(type: ESTree.TSType, environment: TypeEnvironment): WideningTarget | null { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' } + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' } + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : unwrapped.members.length > 0 + ? { kind: 'anonymous object' } + : null + } + if (unwrapped.type === 'TSMappedType') return { kind: 'open dictionary' } + if (unwrapped.type !== 'TSTypeReference') return null + const name = typeReferenceName(unwrapped) + if (name === null) return null + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment) + } + if (name === 'Record' && isBuiltIn(name, environment)) return { kind: 'open dictionary' } + const alias = environment.aliases.get(name) + if (alias === undefined) return null + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()) + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: 'generic container' } + : null + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()) + if (substitutions === null) return null + const resolved = classifyAliasBroadTarget(alias.typeAnnotation, environment, substitutions, new Set([name])) + return resolved +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type) + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true + } + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.every((member) => isBroadMappedKey(member, environment, substitutions)) + } + if (unwrapped.type !== 'TSTypeReference') return false + const name = typeReferenceName(unwrapped) + if (name === null) return false + const substitution = substitutions.get(name) + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions) + } + return name === 'PropertyKey' && isBuiltIn(name, environment) +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' } + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' } + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : null + } + if (unwrapped.type === 'TSMappedType') { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) ? { kind: 'open dictionary' } : null + } + if (unwrapped.type !== 'TSTypeReference') return null + const name = typeReferenceName(unwrapped) + if (name === null) return null + const substitution = substitutions.get(name) + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases) + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases) + } + if (name === 'Record' && isBuiltIn(name, environment)) { + return { kind: 'open dictionary' } + } + const alias = environment.aliases.get(name) + if (alias === undefined || resolvingAliases.has(name)) return null + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return null + const nextResolving = new Set(resolvingAliases) + nextResolving.add(name) + return classifyAliasBroadTarget(alias.typeAnnotation, environment, nextSubstitutions, nextResolving) +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current.type === 'ObjectExpression' && current.properties.length > 0 +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' || + current.type === 'TSSatisfiesExpression' + ) { + current = current.expression + } + if (current.type === 'ObjectExpression') return true + return ( + current.type === 'ArrayExpression' || + current.type === 'ArrowFunctionExpression' || + current.type === 'ClassExpression' || + current.type === 'FunctionExpression' || + current.type === 'NewExpression' || + current.type === 'Literal' || + current.type === 'TemplateLiteral' || + current.type === 'UnaryExpression' + ) +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts index e430f64..e9f106e 100644 --- a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -1,6 +1,6 @@ import type { ESTree } from '@oxlint/plugins' -type VisitorKeys = Readonly>> +type VisitorKeys = Readonly> function isNode(value: unknown): value is ESTree.Node { return typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' @@ -22,6 +22,7 @@ function collectInferTypeParameterNames(node: ESTree.Node, visitorKeys: VisitorK } } +/** Collect type binders that are in scope at a node and can shadow module aliases. */ export function lexicalTypeParameterNames(node: ESTree.Node, visitorKeys: VisitorKeys): ReadonlySet { const names = new Set() let descendant: ESTree.Node = node diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts index 2dda097..5af5ac7 100644 --- a/tools/oxlint/anti-slop/shared/reflect-method.ts +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -17,6 +17,7 @@ function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): return variable === null || variable.defs.length === 0 } +/** Reports whether a call target names one method on the global Reflect object. */ export function isGlobalReflectMethodCall( sourceCode: SourceCode, callee: ESTree.Expression, diff --git a/tools/oxlint/automation/catalog.ts b/tools/oxlint/automation/catalog.ts new file mode 100644 index 0000000..a19082d --- /dev/null +++ b/tools/oxlint/automation/catalog.ts @@ -0,0 +1,134 @@ +export type RuleProfile = 'architecture' | 'core' | 'effect' | 'next-tailwind' | 'react' | 'xstate' + +export type RuleSeverity = 'error' | 'warn' + +export interface OxlintRuleMetadata { + readonly name: string + readonly profile: RuleProfile + readonly severity: RuleSeverity + readonly applicability: string + readonly options: string + readonly exceptions: string +} + +const warningRules = new Set([ + 'no-cascading-layer-provide', + 'no-effect-asvoid', + 'no-nested-layer-provide', + 'no-non-route-layout-files', + 'pipe-max-arguments', + 'prefer-option-from-nullable', +]) + +const profileRules: Readonly>> = { + architecture: [ + 'no-api-backend-imports', + 'no-api-repository-imports', + 'no-comments', + 'no-reexport-only-modules', + 'no-sql-type-parameter', + 'require-tsx-in-ui-folders', + ], + core: [ + 'no-banned-type-assertions', + 'no-multiple-function-params', + 'no-optional-function-parameters', + 'no-single-use-private-functions', + 'no-type-assertion', + 'private-function-prefix', + ], + effect: [ + 'no-ambient-nondeterminism', + 'no-cascading-layer-provide', + 'no-direct-browser-storage', + 'no-direct-fetch', + 'no-disable-validation', + 'no-effect-asvoid', + 'no-global-json', + 'no-in-operator', + 'no-nested-effect-array-methods', + 'no-nested-layer-provide', + 'no-service-option', + 'no-shadowed-standard-array-static', + 'no-silent-error-swallow', + 'no-static-effect-service-forwarders', + 'no-switch', + 'no-try-catch', + 'no-typeof-object', + 'pipe-max-arguments', + 'prefer-effect-match', + 'prefer-option-from-nullable', + 'require-context-service-in-services', + ], + 'next-tailwind': [ + 'no-css-modules', + 'no-fixed-height-on-content', + 'no-jsx-style', + 'no-non-route-layout-files', + 'require-approved-ui-primitives', + 'require-cn-for-classname-composition', + 'require-default-component-export', + ], + react: ['no-react-component-inner-functions', 'no-react-non-component-function-exports', 'no-react-state-hooks'], + xstate: [ + 'no-direct-xstate-create-machine', + 'no-direct-xstate-use-selector', + 'no-multiple-xstate-hooks', + 'no-single-use-xstate-actions', + 'no-single-use-xstate-guards', + 'require-xstate-event-satisfies', + ], +} + +const configurableRules: Readonly> = { + 'no-ambient-nondeterminism': 'allowedDateBasenames, allowedDateExtensions', + 'no-api-backend-imports': 'contractRootMarker, allowedImports', + 'no-api-repository-imports': 'handlerDirectoryNames, repositoryDirectoryNames, testDirectoryNames', + 'no-direct-xstate-use-selector': 'selectorModules, preferredModule', + 'no-fixed-height-on-content': 'allowedHeightUtilities, ignoredPathMarkers', + 'no-jsx-style': 'ignoredPathMarkers', + 'no-multiple-function-params': 'exemptRouteBasenames, exemptFunctionNames', + 'no-non-route-layout-files': 'routeDirectoryNames, allowedLayoutFilenames', + 'no-reexport-only-modules': 'routeDirectoryNames, allowedFilenames', + 'no-react-non-component-function-exports': 'frameworkSegmentBasenames, frameworkSpecialExports', + 'require-approved-ui-primitives': 'applicationPathMarkers, bannedElements, linkModule', + 'require-cn-for-classname-composition': 'utilityModules, utilityName, ignoredPathMarkers', + 'require-context-service-in-services': 'serviceDirectoryNames, ignoredDirectoryNames, ignoredBasenames', + 'require-default-component-export': 'applicationPathMarkers, ignoredFilenamePatterns, wrapperNames', + 'require-tsx-in-ui-folders': 'uiDirectoryNames, allowedTypeScriptFiles', +} + +const exceptionNotes: Readonly> = { + 'no-api-repository-imports': 'Test files are excluded.', + 'no-direct-fetch': 'Use file overrides for scripts or adapters that intentionally own HTTP.', + 'no-reexport-only-modules': 'Framework-reserved route modules and explicit public entry points may need overrides.', + 'no-try-catch': 'Use a scoped override at an integration boundary that must consume throwing APIs.', + 'require-default-component-export': 'Framework-reserved metadata and route handler files are excluded.', +} + +const profileApplicability: Readonly> = { + architecture: 'Layered application and package boundaries', + core: 'TypeScript', + effect: 'Effect', + 'next-tailwind': 'Next.js and Tailwind CSS', + react: 'React', + xstate: 'XState', +} + +export const oxlintRuleMetadata: ReadonlyArray = Object.entries(profileRules).flatMap( + ([profile, names]) => + names.map((name) => ({ + name, + profile: profile as RuleProfile, + severity: warningRules.has(name) ? 'warn' : 'error', + applicability: profileApplicability[profile as RuleProfile], + options: configurableRules[name] ?? 'None.', + exceptions: exceptionNotes[name] ?? 'None.', + })), +) + +export const rulesByProfile = profileRules + +export const severityByRule: Readonly> = Object.fromEntries( + oxlintRuleMetadata.map(({ name, severity }) => [name, severity]), +) diff --git a/tools/oxlint/automation/index.ts b/tools/oxlint/automation/index.ts index daa5bd9..7501006 100644 --- a/tools/oxlint/automation/index.ts +++ b/tools/oxlint/automation/index.ts @@ -1,28 +1,115 @@ import { eslintCompatPlugin } from '@oxlint/plugins' import noAmbientNondeterminism from './rules/no-ambient-nondeterminism.ts' +import noApiBackendImports from './rules/no-api-backend-imports.ts' +import noApiRepositoryImports from './rules/no-api-repository-imports.ts' +import noBannedTypeAssertions from './rules/no-banned-type-assertions.ts' +import noCascadingLayerProvide from './rules/no-cascading-layer-provide.ts' +import noComments from './rules/no-comments.ts' +import noCssModules from './rules/no-css-modules.ts' +import noDirectBrowserStorage from './rules/no-direct-browser-storage.ts' +import noDirectFetch from './rules/no-direct-fetch.ts' +import noDirectXstateCreateMachine from './rules/no-direct-xstate-create-machine.ts' +import noDirectXstateUseSelector from './rules/no-direct-xstate-use-selector.ts' import noDisableValidation from './rules/no-disable-validation.ts' +import noEffectAsvoid from './rules/no-effect-asvoid.ts' +import noFixedHeightOnContent from './rules/no-fixed-height-on-content.ts' +import noGlobalJson from './rules/no-global-json.ts' +import noInOperator from './rules/no-in-operator.ts' +import noJsxStyle from './rules/no-jsx-style.ts' import noManualTagComparison from './rules/no-manual-tag-comparison.ts' import noManualTaggedConstruction from './rules/no-manual-tagged-construction.ts' +import noMultipleFunctionParams from './rules/no-multiple-function-params.ts' +import noMultipleXstateHooks from './rules/no-multiple-xstate-hooks.ts' +import noNestedEffectArrayMethods from './rules/no-nested-effect-array-methods.ts' +import noNestedLayerProvide from './rules/no-nested-layer-provide.ts' +import noNonRouteLayoutFiles from './rules/no-non-route-layout-files.ts' +import noOptionalFunctionParameters from './rules/no-optional-function-parameters.ts' +import noReactComponentInnerFunctions from './rules/no-react-component-inner-functions.ts' +import noReactNonComponentFunctionExports from './rules/no-react-non-component-function-exports.ts' +import noReactStateHooks from './rules/no-react-state-hooks.ts' +import noReexportOnlyModules from './rules/no-reexport-only-modules.ts' import noServiceOption from './rules/no-service-option.ts' import noShadowedStandardArrayStatic from './rules/no-shadowed-standard-array-static.ts' import noSilentErrorSwallow from './rules/no-silent-error-swallow.ts' +import noSingleUsePrivateFunctions from './rules/no-single-use-private-functions.ts' +import noSingleUseXstateActions from './rules/no-single-use-xstate-actions.ts' +import noSingleUseXstateGuards from './rules/no-single-use-xstate-guards.ts' +import noSqlTypeParameter from './rules/no-sql-type-parameter.ts' +import noStaticEffectServiceForwarders from './rules/no-static-effect-service-forwarders.ts' +import noSwitch from './rules/no-switch.ts' +import noTryCatch from './rules/no-try-catch.ts' +import noTypeAssertion from './rules/no-type-assertion.ts' +import noTypeofObject from './rules/no-typeof-object.ts' +import pipeMaxArguments from './rules/pipe-max-arguments.ts' import preferEffectMatch from './rules/prefer-effect-match.ts' import preferOptionFromNullable from './rules/prefer-option-from-nullable.ts' import preferTaggedErrorHandling from './rules/prefer-tagged-error-handling.ts' +import privateFunctionPrefix from './rules/private-function-prefix.ts' +import requireApprovedUiPrimitives from './rules/require-approved-ui-primitives.ts' +import requireCnForClassnameComposition from './rules/require-cn-for-classname-composition.ts' +import requireContextServiceInServices from './rules/require-context-service-in-services.ts' +import requireDefaultComponentExport from './rules/require-default-component-export.ts' +import requireTsxInUiFolders from './rules/require-tsx-in-ui-folders.ts' +import requireXstateEventSatisfies from './rules/require-xstate-event-satisfies.ts' export default eslintCompatPlugin({ meta: { name: 'automation' }, rules: { 'no-ambient-nondeterminism': noAmbientNondeterminism, + 'no-api-backend-imports': noApiBackendImports, + 'no-api-repository-imports': noApiRepositoryImports, + 'no-banned-type-assertions': noBannedTypeAssertions, + 'no-cascading-layer-provide': noCascadingLayerProvide, + 'no-comments': noComments, + 'no-css-modules': noCssModules, + 'no-direct-browser-storage': noDirectBrowserStorage, + 'no-direct-fetch': noDirectFetch, + 'no-direct-xstate-create-machine': noDirectXstateCreateMachine, + 'no-direct-xstate-use-selector': noDirectXstateUseSelector, 'no-disable-validation': noDisableValidation, + 'no-effect-asvoid': noEffectAsvoid, + 'no-fixed-height-on-content': noFixedHeightOnContent, + 'no-global-json': noGlobalJson, + 'no-in-operator': noInOperator, + 'no-jsx-style': noJsxStyle, 'no-manual-tag-comparison': noManualTagComparison, 'no-manual-tagged-construction': noManualTaggedConstruction, + 'no-multiple-function-params': noMultipleFunctionParams, + 'no-multiple-xstate-hooks': noMultipleXstateHooks, + 'no-nested-effect-array-methods': noNestedEffectArrayMethods, + 'no-nested-layer-provide': noNestedLayerProvide, + 'no-non-route-layout-files': noNonRouteLayoutFiles, + 'no-optional-function-parameters': noOptionalFunctionParameters, + 'no-react-component-inner-functions': noReactComponentInnerFunctions, + 'no-react-non-component-function-exports': noReactNonComponentFunctionExports, + 'no-react-state-hooks': noReactStateHooks, + 'no-reexport-only-modules': noReexportOnlyModules, 'no-service-option': noServiceOption, 'no-shadowed-standard-array-static': noShadowedStandardArrayStatic, 'no-silent-error-swallow': noSilentErrorSwallow, + 'no-single-use-private-functions': noSingleUsePrivateFunctions, + 'no-single-use-xstate-actions': noSingleUseXstateActions, + 'no-single-use-xstate-guards': noSingleUseXstateGuards, + 'no-sql-type-parameter': noSqlTypeParameter, + 'no-static-effect-service-forwarders': noStaticEffectServiceForwarders, + 'no-switch': noSwitch, + 'no-try-catch': noTryCatch, + 'no-type-assertion': noTypeAssertion, + 'no-typeof-object': noTypeofObject, + 'pipe-max-arguments': pipeMaxArguments, 'prefer-effect-match': preferEffectMatch, 'prefer-option-from-nullable': preferOptionFromNullable, 'prefer-tagged-error-handling': preferTaggedErrorHandling, + 'private-function-prefix': privateFunctionPrefix, + 'require-approved-ui-primitives': requireApprovedUiPrimitives, + 'require-cn-for-classname-composition': requireCnForClassnameComposition, + 'require-context-service-in-services': requireContextServiceInServices, + 'require-default-component-export': requireDefaultComponentExport, + 'require-tsx-in-ui-folders': requireTsxInUiFolders, + 'require-xstate-event-satisfies': requireXstateEventSatisfies, }, }) + +export { allRules, profile, profiles } from './profiles.ts' +export { oxlintRuleMetadata, rulesByProfile, severityByRule } from './catalog.ts' diff --git a/tools/oxlint/automation/profiles.ts b/tools/oxlint/automation/profiles.ts new file mode 100644 index 0000000..46b0757 --- /dev/null +++ b/tools/oxlint/automation/profiles.ts @@ -0,0 +1,21 @@ +import { oxlintRuleMetadata, rulesByProfile, severityByRule, type RuleProfile } from './catalog.ts' + +type Severity = 'error' | 'warn' + +export const profile = (name: RuleProfile, namespace = 'automation'): Readonly> => + Object.fromEntries( + rulesByProfile[name].map((ruleName) => [`${namespace}/${ruleName}`, severityByRule[ruleName] ?? 'error']), + ) + +export const profiles = Object.freeze({ + architecture: profile('architecture'), + core: profile('core'), + effect: profile('effect'), + nextTailwind: profile('next-tailwind'), + react: profile('react'), + xstate: profile('xstate'), +}) + +export const allRules = Object.freeze( + Object.fromEntries(oxlintRuleMetadata.map(({ name, severity }) => [`automation/${name}`, severity])), +) diff --git a/tools/oxlint/automation/rules/no-api-backend-imports.ts b/tools/oxlint/automation/rules/no-api-backend-imports.ts new file mode 100644 index 0000000..f6e304c --- /dev/null +++ b/tools/oxlint/automation/rules/no-api-backend-imports.ts @@ -0,0 +1,107 @@ +import path from 'node:path' + +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ImportDeclarationNode = Parameters>[0] +type ExportAllDeclarationNode = Parameters>[0] +type ExportNamedDeclarationNode = Parameters>[0] +type ModuleDeclarationNode = ExportAllDeclarationNode | ExportNamedDeclarationNode | ImportDeclarationNode + +interface Options { + readonly allowedImports?: ReadonlyArray + readonly contractRootMarker?: string +} + +const _contractRoot = ({ filename, rootMarker }: { filename: string; rootMarker: string }) => { + const markerIndex = filename.indexOf(rootMarker) + return markerIndex === -1 ? undefined : filename.slice(0, markerIndex + rootMarker.length).replace(/\/$/, '') +} + +const _isLocalImport = ({ + contractRoot, + filename, + source, +}: { + contractRoot: string + filename: string + source: string +}) => { + if (!source.startsWith('.')) { + return false + } + + const resolved = path.posix.resolve(path.posix.dirname(filename), source) + + return resolved === contractRoot || resolved.startsWith(`${contractRoot}/`) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Keep the public contract area independent from backend and persistence modules.', + }, + schema: [ + { + type: 'object', + properties: { + allowedImports: { + type: 'array', + items: { type: 'string' }, + }, + contractRootMarker: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const rootMarker = options.contractRootMarker ?? '/src/contracts/' + const allowedImports = options.allowedImports ?? ['@scope/contracts', 'effect'] + const contractRoot = _contractRoot({ filename, rootMarker }) + + if (contractRoot === undefined) { + return {} + } + const isAllowedImport = ({ source }: { source: string }) => + allowedImports.some((allowed) => source === allowed || source.startsWith(`${allowed}/`)) + + const checkSource = ({ node, source }: { node: ModuleDeclarationNode; source: unknown }) => { + if ( + typeof source !== 'string' || + isAllowedImport({ source }) || + _isLocalImport({ + contractRoot, + filename, + source, + }) + ) { + return + } + + context.report({ + node, + message: + 'The public contract area may import only local modules and explicitly allowed dependencies. Keep backend and persistence models in their owning application.', + }) + } + + return { + ExportAllDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + ExportNamedDeclaration(node) { + checkSource({ node, source: node.source?.value }) + }, + ImportDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-api-repository-imports.ts b/tools/oxlint/automation/rules/no-api-repository-imports.ts new file mode 100644 index 0000000..f7c04ba --- /dev/null +++ b/tools/oxlint/automation/rules/no-api-repository-imports.ts @@ -0,0 +1,75 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly handlerDirectoryNames?: ReadonlyArray + readonly repositoryDirectoryNames?: ReadonlyArray + readonly testDirectoryNames?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Prevent API and route handlers from importing repositories directly; call the owning service instead.', + }, + schema: [ + { + type: 'object', + properties: { + handlerDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + repositoryDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + testDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const handlerDirectoryNames = new Set(options.handlerDirectoryNames ?? ['api', 'routes']) + const repositoryDirectoryNames = new Set(options.repositoryDirectoryNames ?? ['repository', 'repositories']) + const testDirectoryNames = new Set(options.testDirectoryNames ?? ['test', 'tests', '__tests__']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const filenameSegments = filename.split('/') + + if ( + !filenameSegments.some((segment) => handlerDirectoryNames.has(segment)) || + filenameSegments.some((segment) => testDirectoryNames.has(segment)) + ) { + return {} + } + + return { + ImportDeclaration(node) { + if ( + typeof node.source.value !== 'string' || + !node.source.value + .replaceAll('\\', '/') + .split('/') + .some((segment) => repositoryDirectoryNames.has(segment)) + ) { + return + } + + context.report({ + node, + message: 'API and route handlers must call a service instead of importing a repository directly.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-banned-type-assertions.ts b/tools/oxlint/automation/rules/no-banned-type-assertions.ts new file mode 100644 index 0000000..c2708e0 --- /dev/null +++ b/tools/oxlint/automation/rules/no-banned-type-assertions.ts @@ -0,0 +1,38 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const bannedTypes = new Set(['TSAnyKeyword', 'TSNeverKeyword', 'TSUnknownKeyword']) + +const message = 'Do not assert to any, never, or unknown. Fix the type or use generics.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow assertions to any, never, or unknown; fix the type or use generics.', + }, + }, + create(context) { + return { + TSAsExpression(node) { + if (bannedTypes.has(node.typeAnnotation.type)) { + context.report({ + node, + message, + }) + } + }, + TSTypeAssertion(node) { + if (bannedTypes.has(node.typeAnnotation.type)) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-cascading-layer-provide.ts b/tools/oxlint/automation/rules/no-cascading-layer-provide.ts new file mode 100644 index 0000000..1cddada --- /dev/null +++ b/tools/oxlint/automation/rules/no-cascading-layer-provide.ts @@ -0,0 +1,98 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type CallExpressionNode = Parameters>[0] +type IdentifierNode = Extract + +const provisioningMethods = new Set(['provide', 'provideMerge']) + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Combine independent Layer dependencies in one provide call and extract intentional dependency stages.', + }, + }, + create(context) { + const findImportDefinition = ({ node }: { node: IdentifierNode }) => { + let scope: ReturnType | null = context.sourceCode.getScope(node) + + while (scope !== null) { + const variable = scope.set.get(node.name) + + if (variable !== undefined) { + return variable.defs.find( + (definition) => + definition.type === 'ImportBinding' && definition.parent?.type === 'ImportDeclaration', + ) + } + + scope = scope.upper + } + + return undefined + } + + const isEffectLayerIdentifier = ({ node }: { node: IdentifierNode }) => { + const definition = findImportDefinition({ node }) + + return ( + definition?.parent?.type === 'ImportDeclaration' && + definition.parent.source.value === 'effect' && + definition.node.type === 'ImportSpecifier' && + (definition.node.imported.type === 'Identifier' + ? definition.node.imported.name + : definition.node.imported.value) === 'Layer' + ) + } + + const isLayerProvision = ({ node }: { node: CallExpressionNode['arguments'][number] }) => { + if ( + node.type !== 'CallExpression' || + node.callee.type !== 'MemberExpression' || + node.callee.object.type !== 'Identifier' || + !isEffectLayerIdentifier({ node: node.callee.object }) + ) { + return false + } + + const property = node.callee.property + const propertyName = + property.type === 'Identifier' + ? property.name + : property.type === 'Literal' && typeof property.value === 'string' + ? property.value + : undefined + + return provisioningMethods.has(propertyName ?? '') + } + + return { + CallExpression(node) { + if ( + node.callee.type !== 'MemberExpression' || + node.callee.property.type !== 'Identifier' || + node.callee.property.name !== 'pipe' + ) { + return + } + + const provisioningStages = node.arguments.filter((argument) => isLayerProvision({ node: argument })) + + if (provisioningStages.length < 2) { + return + } + + context.report({ + node, + message: + 'Avoid multiple Layer.provide or Layer.provideMerge stages in one pipe. Combine independent dependencies in one Layer.provide([...]); when a layer depends on another layer, extract and name that configured layer before providing it.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-comments.ts b/tools/oxlint/automation/rules/no-comments.ts new file mode 100644 index 0000000..6fc3fc2 --- /dev/null +++ b/tools/oxlint/automation/rules/no-comments.ts @@ -0,0 +1,40 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const allowedDirective = /^(?:\/\s*[1] +type VisitorObject = ReturnType> +type ImportDeclarationNode = Parameters>[0] +type ExportAllDeclarationNode = Parameters>[0] +type ExportNamedDeclarationNode = Parameters>[0] +type ImportExpressionNode = Parameters>[0] +type CallExpressionNode = Parameters>[0] +type ReportableNode = + | CallExpressionNode + | ExportAllDeclarationNode + | ExportNamedDeclarationNode + | ImportDeclarationNode + | ImportExpressionNode + +const cssModulePattern = /\.module\.(?:css|less|sass|scss)$/i +const message = 'CSS Modules are not allowed. Use Tailwind utilities instead.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow CSS Modules so web styling stays in Tailwind utilities.', + }, + }, + create(context) { + const checkSource = ({ node, source }: { node: ReportableNode; source: unknown }) => { + if (typeof source === 'string' && cssModulePattern.test(source)) { + context.report({ node, message }) + } + } + + return { + CallExpression(node) { + if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') { + return + } + + const source = node.arguments[0] + checkSource({ + node, + source: source?.type === 'Literal' ? source.value : undefined, + }) + }, + ExportAllDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + ExportNamedDeclaration(node) { + checkSource({ node, source: node.source?.value }) + }, + ImportDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + ImportExpression(node) { + checkSource({ + node, + source: node.source.type === 'Literal' ? node.source.value : undefined, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-browser-storage.ts b/tools/oxlint/automation/rules/no-direct-browser-storage.ts new file mode 100644 index 0000000..53a54c9 --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-browser-storage.ts @@ -0,0 +1,100 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const bannedBrowserStorageNames = new Set(['indexedDB', 'localStorage', 'sessionStorage']) + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const getStorageIdentifierName = (node: unknown) => + isNode(node) && + node.type === 'Identifier' && + typeof node.name === 'string' && + bannedBrowserStorageNames.has(node.name) + ? node.name + : undefined + +const messageFor = ({ name }: { name: string }) => + `Do not use ${name} directly. Use Effect's IndexedDb or KeyValueStore modules instead.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow direct browser storage APIs in favor of Effect storage modules.', + }, + }, + create(context) { + return { + Identifier(node) { + const storageName = getStorageIdentifierName(node) + + if (storageName === undefined) { + return + } + + const parent = isNode(node.parent) ? node.parent : undefined + + if ( + (parent?.type === 'Property' && + parent.key === node && + (!isNode(parent.parent) || parent.parent.type !== 'ObjectPattern') && + parent.value !== node) || + (parent?.type === 'MemberExpression' && parent.property === node && parent.computed !== true) || + (parent?.type === 'ImportSpecifier' && parent.imported === node && parent.local !== node) || + (parent?.type === 'MemberExpression' && parent.object === node) + ) { + return + } + + context.report({ + node, + message: messageFor({ name: storageName }), + }) + }, + MemberExpression(node) { + const storageName = + node.object.type === 'Identifier' ? getStorageIdentifierName(node.object) : undefined + + if (storageName !== undefined) { + context.report({ + node, + message: messageFor({ name: storageName }), + }) + return + } + + if ( + node.object.type === 'Identifier' && + (node.object.name === 'globalThis' || node.object.name === 'window') + ) { + const propertyName = + node.property.type === 'Identifier' + ? node.property.name + : node.property.type === 'Literal' && typeof node.property.value === 'string' + ? node.property.value + : undefined + + if (!bannedBrowserStorageNames.has(propertyName ?? '')) { + return + } + + context.report({ + node, + message: messageFor({ + name: propertyName ?? '', + }), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-fetch.ts b/tools/oxlint/automation/rules/no-direct-fetch.ts new file mode 100644 index 0000000..6046de6 --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-fetch.ts @@ -0,0 +1,44 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Do not call fetch directly. Use the existing API client or runtime client service.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid direct fetch calls; use the existing API client or runtime client service.', + }, + }, + create(context) { + return { + CallExpression(node) { + const callee = node.callee + + if (callee.type === 'Identifier' && callee.name === 'fetch') { + context.report({ + node, + message, + }) + return + } + + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'fetch' && + callee.object.type === 'Identifier' && + (callee.object.name === 'window' || callee.object.name === 'globalThis') + ) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts b/tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts new file mode 100644 index 0000000..2bfd34a --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts @@ -0,0 +1,58 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Do not use createMachine directly. Define machines with setup().createMachine().' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Create XState machines with setup().createMachine() to define their typed dependencies.', + }, + }, + create(context) { + const namespaceNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'xstate') { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceNames.add(specifier.local.name) + continue + } + + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'createMachine' + ) { + context.report({ + node: specifier, + message, + }) + } + } + }, + MemberExpression(node) { + if ( + node.object.type === 'Identifier' && + namespaceNames.has(node.object.name) && + node.property.type === 'Identifier' && + node.property.name === 'createMachine' + ) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts b/tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts new file mode 100644 index 0000000..a878e1a --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts @@ -0,0 +1,84 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly preferredModule?: string + readonly selectorModules?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Import typed selector hooks from a repository-owned facade instead of using XState useSelector directly.', + }, + schema: [ + { + type: 'object', + properties: { + preferredModule: { type: 'string' }, + selectorModules: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const xstateSelectorModules = new Set(options.selectorModules ?? ['@xstate/react', '@xstate/store/react']) + const preferredModule = options.preferredModule ?? 'a repository-owned typed selector facade' + const message = `Use selector hooks from ${preferredModule} instead of XState useSelector directly.` + const isXstateSelectorModule = ({ value }: { value: unknown }) => + typeof value === 'string' && xstateSelectorModules.has(value) + const namespaceNames = new Set() + + return { + ImportDeclaration(node) { + if (!isXstateSelectorModule({ value: node.source.value })) { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceNames.add(specifier.local.name) + continue + } + + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'useSelector' + ) { + context.report({ + node: specifier, + message, + }) + } + } + }, + MemberExpression(node) { + const isDirectNamespaceSelector = + node.object.type === 'Identifier' && + namespaceNames.has(node.object.name) && + node.property.type === 'Identifier' && + node.property.name === 'useSelector' + + if (!isDirectNamespaceSelector) { + return + } + + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-effect-asvoid.ts b/tools/oxlint/automation/rules/no-effect-asvoid.ts new file mode 100644 index 0000000..c7b4b20 --- /dev/null +++ b/tools/oxlint/automation/rules/no-effect-asvoid.ts @@ -0,0 +1,33 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Avoid Effect.asVoid. Prefer returning the effect directly when the success type is void.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid Effect.asVoid; return effects whose success type is already void directly.', + }, + }, + create(context) { + return { + MemberExpression(node) { + if ( + node.object.type === 'Identifier' && + node.object.name === 'Effect' && + node.property.type === 'Identifier' && + node.property.name === 'asVoid' + ) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-fixed-height-on-content.ts b/tools/oxlint/automation/rules/no-fixed-height-on-content.ts new file mode 100644 index 0000000..fe7ccc9 --- /dev/null +++ b/tools/oxlint/automation/rules/no-fixed-height-on-content.ts @@ -0,0 +1,286 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +interface Options { + readonly allowedHeightUtilities?: ReadonlyArray + readonly ignoredPathMarkers?: ReadonlyArray +} +type VisitorObject = ReturnType> +type JSXOpeningElementNode = Parameters>[0] +type JSXElementNode = Extract +type JSXChildNode = JSXElementNode['children'][number] + +const contentIntrinsicElements = new Set([ + 'a', + 'button', + 'dd', + 'dt', + 'figcaption', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'label', + 'legend', + 'li', + 'p', + 'summary', +]) + +const directContentElements = new Set(['article', 'div', 'footer', 'header', 'main', 'section', 'span']) + +const fixedHeightControlElements = new Set(['input', 'select', 'textarea']) + +const visualIntrinsicElements = new Set(['canvas', 'iframe', 'img', 'picture', 'svg', 'video']) + +const mediaIntrinsicElements = new Set(['canvas', 'img', 'picture', 'video']) + +const relativeHeightUtilities = new Set([ + 'h-auto', + 'h-dvh', + 'h-fit', + 'h-full', + 'h-lvh', + 'h-max', + 'h-min', + 'h-screen', + 'h-svh', + 'size-auto', + 'size-fit', + 'size-full', + 'size-max', + 'size-min', +]) + +const semanticGeometricHeightUtilities = new Set(['h-chart', 'h-panel', 'h-skeleton-line', 'h-table-row']) + +const contentComponentName = + /(?:Badge|Button|Chip|Description|Heading|Label|Link|ListBoxItem|MenuItem|Option|Paragraph|Tag|Text|Title)$/ + +const fixedHeightControlComponentName = /(?:ComboBox|Input|SearchBar|SearchField|Select|TextArea|TextField)$/ + +const fixedHeightControlComponentNames = new Set(['Group']) + +const visualComponentName = /(?:Avatar|Icon|Illustration|Image|Logo|Spinner|Svg)$/ + +const mediaComponentName = /(?:Avatar|Illustration|Image)$/ + +const _elementName = ({ node }: { node: JSXOpeningElementNode }) => + node.name.type === 'JSXIdentifier' ? node.name.name : undefined + +const _hasAttribute = ({ name, node }: { name: string; node: JSXOpeningElementNode }) => + node.attributes.some( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === name, + ) + +const _isVisibleExpression = ({ node }: { node: JSXChildNode }) => + node.type === 'JSXExpressionContainer' && node.expression.type !== 'JSXEmptyExpression' + +const _hasTextualContent = ({ children }: { children: JSXElementNode['children'] }): boolean => + children.some((child) => { + if (child.type === 'JSXText') { + return child.value.trim().length > 0 + } + + if (_isVisibleExpression({ node: child })) { + return true + } + + if (child.type === 'JSXFragment') { + return _hasTextualContent({ children: child.children }) + } + + if (child.type !== 'JSXElement') { + return false + } + + const name = _elementName({ node: child.openingElement }) + + if (name !== undefined && (visualIntrinsicElements.has(name) || visualComponentName.test(name))) { + return false + } + + return _hasTextualContent({ children: child.children }) + }) + +const _hasDirectTextualContent = ({ children }: { children: JSXElementNode['children'] }) => + children.some((child) => child.type === 'JSXText' && child.value.trim().length > 0) + +const _hasConfidentTextualContent = ({ children }: { children: JSXElementNode['children'] }): boolean => + children.some((child) => { + if (child.type === 'JSXText') { + return child.value.trim().length > 0 + } + + if (child.type === 'JSXFragment') { + return _hasConfidentTextualContent({ children: child.children }) + } + + if (child.type !== 'JSXElement') { + return false + } + + const name = _elementName({ node: child.openingElement }) + + if (name !== undefined && (visualIntrinsicElements.has(name) || visualComponentName.test(name))) { + return false + } + + return _hasConfidentTextualContent({ children: child.children }) + }) + +const _hasMediaContent = ({ children }: { children: JSXElementNode['children'] }): boolean => + children.some((child) => { + if (child.type === 'JSXFragment') { + return _hasMediaContent({ children: child.children }) + } + + if (child.type !== 'JSXElement') { + return false + } + + const name = _elementName({ node: child.openingElement }) + + if (name !== undefined && (mediaIntrinsicElements.has(name) || mediaComponentName.test(name))) { + return true + } + + return _hasMediaContent({ children: child.children }) + }) + +const _utilityName = ({ token }: { token: string }) => token.split(':').at(-1)?.replace(/^!/, '') + +const _fixedHeightUtilities = ({ + allowedHeightUtilities, + source, +}: { + allowedHeightUtilities: ReadonlySet + source: string +}) => { + const utilities = new Set() + + for (const match of source.matchAll(/[^\s"'`(){}>,]+/g)) { + const token = match[0] + const utility = _utilityName({ token }) + + if ( + utility === undefined || + relativeHeightUtilities.has(utility) || + allowedHeightUtilities.has(utility) || + (!utility.startsWith('h-') && !utility.startsWith('size-')) + ) { + continue + } + + utilities.add(token) + } + + return [...utilities] +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require content-bearing web elements to derive their height from typography and padding.', + }, + schema: [ + { + type: 'object', + properties: { + allowedHeightUtilities: { + type: 'array', + items: { type: 'string' }, + }, + ignoredPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const allowedHeightUtilities = new Set([ + ...semanticGeometricHeightUtilities, + ...(options.allowedHeightUtilities ?? []), + ]) + const ignoredPathMarkers = options.ignoredPathMarkers ?? ['/native/'] + + if (!filename.endsWith('.tsx') || ignoredPathMarkers.some((marker) => filename.includes(marker))) { + return {} + } + + return { + JSXOpeningElement(node) { + const name = _elementName({ node }) + const element = node.parent + + if ( + name === undefined || + element.type !== 'JSXElement' || + _hasAttribute({ name: 'aria-hidden', node }) || + visualIntrinsicElements.has(name) || + visualComponentName.test(name) || + ((name === 'a' || name === 'Link') && _hasMediaContent({ children: element.children })) + ) { + return + } + + const isContentBearing = + contentIntrinsicElements.has(name) || + fixedHeightControlElements.has(name) || + fixedHeightControlComponentName.test(name) || + fixedHeightControlComponentNames.has(name) || + (directContentElements.has(name) && _hasDirectTextualContent({ children: element.children })) || + (contentComponentName.test(name) && _hasTextualContent({ children: element.children })) + + if (!isContentBearing) { + return + } + + for (const attribute of node.attributes) { + if ( + attribute.type !== 'JSXAttribute' || + attribute.name.type !== 'JSXIdentifier' || + attribute.name.name !== 'className' + ) { + continue + } + + const utilities = _fixedHeightUtilities({ + allowedHeightUtilities, + source: context.sourceCode.getText(attribute), + }).filter((token) => { + const utility = _utilityName({ token }) + + return ( + utility?.startsWith('size-') !== true || + (!_hasAttribute({ name: 'aria-label', node }) && + _hasConfidentTextualContent({ children: element.children })) + ) + }) + + if (utilities.length === 0) { + continue + } + + context.report({ + node: attribute, + message: `Content-bearing elements should derive their height from line-height and vertical padding instead of ${utilities.join(', ')}. Use min-h-* for a required minimum or an approved geometric utility when the fixed height is intentional.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-global-json.ts b/tools/oxlint/automation/rules/no-global-json.ts new file mode 100644 index 0000000..9cbb4dd --- /dev/null +++ b/tools/oxlint/automation/rules/no-global-json.ts @@ -0,0 +1,67 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +const message = 'Do not use the global JSON API. Use Effect Schema encode/decode APIs instead.' + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _isIdentifier = ({ name, node }: { name: string; node: unknown }) => + _isNode(node) && node.type === 'Identifier' && _nodeField({ field: 'name', node }) === name + +const _isGlobalJson = (node: unknown): boolean => { + if (_isIdentifier({ name: 'JSON', node })) { + return true + } + + if (!_isNode(node) || node.type !== 'MemberExpression') { + return false + } + + return ( + _isIdentifier({ + name: 'globalThis', + node: _nodeField({ field: 'object', node }), + }) && + _isIdentifier({ + name: 'JSON', + node: _nodeField({ field: 'property', node }), + }) + ) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid global JSON APIs; encode and decode JSON with Effect Schema.', + }, + }, + create(context) { + return { + MemberExpression(node) { + if (!_isGlobalJson(node.object)) { + return + } + + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-in-operator.ts b/tools/oxlint/automation/rules/no-in-operator.ts new file mode 100644 index 0000000..d353c54 --- /dev/null +++ b/tools/oxlint/automation/rules/no-in-operator.ts @@ -0,0 +1,30 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Avoid object key checks with the in operator; refactor the check or use Predicate as a last resort.', + }, + }, + create(context) { + return { + BinaryExpression(node) { + if (node.operator !== 'in') { + return + } + + context.report({ + node, + message: + 'Do not use the "in" operator to check for object keys. Fix or refactor the code so this key check is not needed. Only use Predicate as a last-resort escape hatch.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-jsx-style.ts b/tools/oxlint/automation/rules/no-jsx-style.ts new file mode 100644 index 0000000..5177e36 --- /dev/null +++ b/tools/oxlint/automation/rules/no-jsx-style.ts @@ -0,0 +1,51 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly ignoredPathMarkers?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow JSX style props in web code so styling stays in Tailwind theme utilities.', + }, + schema: [ + { + type: 'object', + properties: { + ignoredPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const ignoredPathMarkers = options.ignoredPathMarkers ?? ['/native/'] + + if (!filename.endsWith('.tsx') || ignoredPathMarkers.some((marker) => filename.includes(marker))) { + return {} + } + + return { + JSXAttribute(node) { + if (node.name.type === 'JSXIdentifier' && node.name.name === 'style') { + context.report({ + node, + message: + 'Do not use JSX style props in web code. Use an existing Tailwind theme utility or the closest available theme value.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-multiple-function-params.ts b/tools/oxlint/automation/rules/no-multiple-function-params.ts new file mode 100644 index 0000000..4ba1655 --- /dev/null +++ b/tools/oxlint/automation/rules/no-multiple-function-params.ts @@ -0,0 +1,126 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type FunctionDeclarationNode = Parameters>[0] +type FunctionExpressionNode = Parameters>[0] +type ArrowFunctionExpressionNode = Parameters>[0] +type FunctionNode = FunctionDeclarationNode | FunctionExpressionNode | ArrowFunctionExpressionNode + +interface Options { + readonly exemptFunctionNames?: ReadonlyArray + readonly exemptRouteBasenames?: ReadonlyArray +} + +const maxParams = 1 + +const httpMethods = new Set(['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']) + +const message = 'Functions with more than one parameter must accept a single object parameter.' + +const _isCallback = (parent: { readonly type: string } | null | undefined) => + parent?.type === 'CallExpression' || parent?.type === 'NewExpression' || parent?.type === 'JSXExpressionContainer' + +const _isOwnedFunction = (node: FunctionNode) => { + if (node.type === 'FunctionDeclaration') { + return true + } + + const parent = node.parent + + if (_isCallback(parent)) { + return false + } + + return ( + parent?.type === 'VariableDeclarator' || + parent?.type === 'AssignmentExpression' || + parent?.type === 'ExportDefaultDeclaration' + ) +} + +const _isRouteFile = ({ basenames, filename }: { basenames: ReadonlySet; filename: string }) => + basenames.has(filename.replaceAll('\\', '/').split('/').at(-1) ?? '') + +const _identifierName = ({ node }: { node: { readonly name?: string; readonly type: string } | null }) => + node?.type === 'Identifier' ? node.name : undefined + +const _isExportedHttpRouteHandler = ({ + filename, + httpRouteFunctionNames, + routeBasenames, + node, +}: { + filename: string + httpRouteFunctionNames: ReadonlySet + routeBasenames: ReadonlySet + node: FunctionNode +}) => { + if (!_isRouteFile({ basenames: routeBasenames, filename }) || node.type !== 'FunctionDeclaration') { + return false + } + + const parent = node.parent + const name = _identifierName({ node: node.id }) + + return parent?.type === 'ExportNamedDeclaration' && name !== undefined && httpRouteFunctionNames.has(name) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require internally defined functions with multiple inputs to accept one object parameter.', + }, + schema: [ + { + type: 'object', + properties: { + exemptFunctionNames: { + type: 'array', + items: { type: 'string' }, + }, + exemptRouteBasenames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const httpRouteFunctionNames = new Set(options.exemptFunctionNames ?? httpMethods) + const routeBasenames = new Set(options.exemptRouteBasenames ?? ['route.ts', 'route.tsx']) + const filename = context.filename ?? '' + + const check = (node: FunctionNode) => { + if ( + node.params.length <= maxParams || + !_isOwnedFunction(node) || + _isExportedHttpRouteHandler({ + filename, + httpRouteFunctionNames, + routeBasenames, + node, + }) + ) { + return + } + + context.report({ + node, + message, + }) + } + + return { + FunctionDeclaration: check, + FunctionExpression: check, + ArrowFunctionExpression: check, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts b/tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts new file mode 100644 index 0000000..87e8f47 --- /dev/null +++ b/tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts @@ -0,0 +1,136 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Component = { name: string; node: object } +type ParentNode = { + id?: unknown + parent?: ParentNode | null + type: string +} +type FunctionNode = ParentNode + +const xstateActorHooks = new Set(['useActor', 'useActorRef', 'useMachine']) + +const isComponentName = (name: string | undefined) => name !== undefined && /^[A-Z]/.test(name) + +const identifierName = ({ node }: { node: unknown }) => { + if (typeof node !== 'object' || node === null) { + return undefined + } + + if (!('name' in node) || typeof node.name !== 'string') { + return undefined + } + + return node.name +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Disallow multiple @xstate/react actor hooks in the same component. Compose machines with actors instead.', + }, + }, + create(context) { + const localHookNames = new Set() + const namespaceNames = new Set() + const componentHookCounts = new WeakMap() + const functionStack = new Array() + + const componentName = (node: FunctionNode): string | undefined => { + if (node.type === 'FunctionDeclaration') { + const name = identifierName({ node: node.id }) + + return isComponentName(name) ? name : undefined + } + + const parent = node.parent + + if (parent?.type === 'VariableDeclarator') { + const name = identifierName({ node: parent.id }) + + return isComponentName(name) ? name : undefined + } + + if (parent?.type === 'CallExpression') { + const variableDeclarator = parent.parent + + if (variableDeclarator?.type === 'VariableDeclarator') { + const name = identifierName({ node: variableDeclarator.id }) + + return isComponentName(name) ? name : undefined + } + } + + return undefined + } + + const enterFunction = (node: FunctionNode) => { + const name = componentName(node) + + functionStack.push(name === undefined ? undefined : { name, node }) + } + + const exitFunction = () => { + functionStack.pop() + } + + return { + ArrowFunctionExpression: enterFunction, + 'ArrowFunctionExpression:exit': exitFunction, + FunctionDeclaration: enterFunction, + 'FunctionDeclaration:exit': exitFunction, + FunctionExpression: enterFunction, + 'FunctionExpression:exit': exitFunction, + ImportDeclaration(node) { + if (node.source.value !== '@xstate/react') return + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceNames.add(specifier.local.name) + } else if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + xstateActorHooks.has(specifier.imported.name) + ) { + localHookNames.add(specifier.local.name) + } + } + }, + CallExpression(node) { + const callee = node.callee + const hookName = + callee.type === 'Identifier' && localHookNames.has(callee.name) + ? callee.name + : callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' && + namespaceNames.has(callee.object.name) && + xstateActorHooks.has(callee.property.name) + ? callee.property.name + : undefined + + if (hookName === undefined) return + + const component = functionStack.at(-1) + + if (component === undefined) return + + const currentCount = componentHookCounts.get(component.node) ?? 0 + componentHookCounts.set(component.node, currentCount + 1) + + if (currentCount === 0) return + + context.report({ + node: callee, + message: `${component.name} uses multiple @xstate/react actor hooks. Compose machines with actors instead.`, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-nested-effect-array-methods.ts b/tools/oxlint/automation/rules/no-nested-effect-array-methods.ts new file mode 100644 index 0000000..3744e56 --- /dev/null +++ b/tools/oxlint/automation/rules/no-nested-effect-array-methods.ts @@ -0,0 +1,117 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const ignoredKeys = new Set(['parent']) + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const nodeName = ({ value }: { value: unknown }) => + isNode(value) && typeof value.name === 'string' ? value.name : undefined + +const isEffectArrayCall = ({ node }: { node: unknown }) => + isNode(node) && + node.type === 'CallExpression' && + isNode(node.callee) && + node.callee.type === 'MemberExpression' && + isNode(node.callee.object) && + isNode(node.callee.property) && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Array' && + node.callee.property.type === 'Identifier' + +const containsEffectArrayCall = ({ node, seen }: { node: unknown; seen: WeakSet }): boolean => { + if (node === null || typeof node !== 'object') { + return false + } + + if (seen.has(node)) { + return false + } + + seen.add(node) + + if (isEffectArrayCall({ node })) { + return true + } + + for (const [key, value] of Object.entries(node)) { + if (ignoredKeys.has(key)) { + continue + } + + if (Array.isArray(value)) { + if (value.some((item) => containsEffectArrayCall({ node: item, seen }))) { + return true + } + + continue + } + + if (containsEffectArrayCall({ node: value, seen })) { + return true + } + } + + return false +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid nested Effect Array method calls; compose them with pipe to preserve inference.', + }, + }, + create(context) { + let arrayImportedFromEffect = false + + return { + ImportDeclaration(node) { + if ( + isNode(node) && + isNode(node.source) && + node.source.value === 'effect' && + Array.isArray(node.specifiers) && + node.specifiers + .filter(isNode) + .some( + (specifier) => + specifier.type === 'ImportSpecifier' && + nodeName({ value: specifier.imported }) === 'Array' && + nodeName({ value: specifier.local }) === 'Array', + ) + ) { + arrayImportedFromEffect = true + } + }, + CallExpression(node) { + if (!arrayImportedFromEffect || !isEffectArrayCall({ node })) { + return + } + + if ( + node.arguments.some((argument) => + containsEffectArrayCall({ + node: argument, + seen: new WeakSet(), + }), + ) + ) { + context.report({ + node, + message: 'Do not nest Effect Array method calls. Use pipe to preserve inference.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-nested-layer-provide.ts b/tools/oxlint/automation/rules/no-nested-layer-provide.ts new file mode 100644 index 0000000..ac1ceb1 --- /dev/null +++ b/tools/oxlint/automation/rules/no-nested-layer-provide.ts @@ -0,0 +1,53 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const isLayerProvide = (node: unknown) => + isNode(node) && + node.type === 'CallExpression' && + isNode(node.callee) && + node.callee.type === 'MemberExpression' && + isNode(node.callee.object) && + isNode(node.callee.property) && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Layer' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'provide' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid nested Layer.provide calls; extract the inner layer or use Layer.provideMerge.', + }, + }, + create(context) { + return { + CallExpression(node) { + if (!isLayerProvide(node)) { + return + } + + for (const argument of node.arguments) { + if (isLayerProvide(argument)) { + context.report({ + node: argument, + message: + 'Avoid nested Layer.provide calls. Extract the inner layer or use Layer.provideMerge.', + }) + } + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-non-route-layout-files.ts b/tools/oxlint/automation/rules/no-non-route-layout-files.ts new file mode 100644 index 0000000..ad27f42 --- /dev/null +++ b/tools/oxlint/automation/rules/no-non-route-layout-files.ts @@ -0,0 +1,60 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly allowedLayoutFilenames?: ReadonlyArray + readonly routeDirectoryNames?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: + 'Avoid non-route layout files in app folders; rename them or use the nearest Next.js layout.tsx.', + }, + schema: [ + { + type: 'object', + properties: { + allowedLayoutFilenames: { + type: 'array', + items: { type: 'string' }, + }, + routeDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const allowedLayoutFilenames = new Set(options.allowedLayoutFilenames ?? ['layout.tsx', '_layout.tsx']) + const routeDirectoryNames = new Set(options.routeDirectoryNames ?? ['app']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1).toLowerCase() + + if ( + !filename.split('/').some((segment) => routeDirectoryNames.has(segment)) || + !basename.includes('layout') || + allowedLayoutFilenames.has(basename) + ) { + return {} + } + + return { + Program(node) { + context.report({ + node, + message: 'Rename this file or use the nearest Next.js "layout.tsx" route primitive.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-optional-function-parameters.ts b/tools/oxlint/automation/rules/no-optional-function-parameters.ts new file mode 100644 index 0000000..d65e9d1 --- /dev/null +++ b/tools/oxlint/automation/rules/no-optional-function-parameters.ts @@ -0,0 +1,67 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const message = 'Optional function parameters are banned. Use an explicit union with undefined or null.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid optional function parameters; use an explicit union with undefined or null.', + }, + }, + create(context) { + return { + FunctionDeclaration(node) { + for (const param of node.params) { + if ( + isNode(param) && + (param.optional === true || (isNode(param.parameter) && param.parameter.optional === true)) + ) { + context.report({ + node: param, + message, + }) + } + } + }, + FunctionExpression(node) { + for (const param of node.params) { + if ( + isNode(param) && + (param.optional === true || (isNode(param.parameter) && param.parameter.optional === true)) + ) { + context.report({ + node: param, + message, + }) + } + } + }, + ArrowFunctionExpression(node) { + for (const param of node.params) { + if ( + isNode(param) && + (param.optional === true || (isNode(param.parameter) && param.parameter.optional === true)) + ) { + context.report({ + node: param, + message, + }) + } + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-react-component-inner-functions.ts b/tools/oxlint/automation/rules/no-react-component-inner-functions.ts new file mode 100644 index 0000000..c094085 --- /dev/null +++ b/tools/oxlint/automation/rules/no-react-component-inner-functions.ts @@ -0,0 +1,266 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type FunctionDeclarationNode = Parameters>[0] +type FunctionExpressionNode = Parameters>[0] +type ArrowFunctionExpressionNode = Parameters>[0] +type FunctionNode = FunctionDeclarationNode | FunctionExpressionNode | ArrowFunctionExpressionNode + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +const functionTypes = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']) + +const expressionWrapperTypes = new Set([ + 'ChainExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _nodeArray = ({ field, node }: { field: string; node: Node }) => { + const value = _nodeField({ field, node }) + + return Array.isArray(value) ? value.filter(_isNode) : [] +} + +const _nodeName = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return undefined + } + + const name = _nodeField({ field: 'name', node }) + + return typeof name === 'string' ? name : undefined +} + +const _nodeParent = ({ node }: { node: Node }) => { + const parent = _nodeField({ field: 'parent', node }) + + return _isNode(parent) ? parent : undefined +} + +const _nodeExpression = ({ node }: { node: Node }) => { + const expression = _nodeField({ field: 'expression', node }) + + return _isNode(expression) ? expression : undefined +} + +const _isTsxFile = ({ filename }: { filename: string }) => filename.endsWith('.tsx') + +const _isFunction = ({ node }: { node: Node | undefined }) => node !== undefined && functionTypes.has(node.type) + +const _isComponentName = ({ name }: { name: string | undefined }) => name !== undefined && /^[A-Z]/.test(name) + +const _componentName = ({ node }: { node: Node }): string | undefined => { + if (node.type === 'FunctionDeclaration') { + const name = _nodeName({ node: _nodeField({ field: 'id', node }) }) + + return _isComponentName({ name }) ? name : undefined + } + + const parent = _nodeParent({ node }) + + if (parent?.type === 'VariableDeclarator') { + const name = _nodeName({ node: _nodeField({ field: 'id', node: parent }) }) + + return _isComponentName({ name }) ? name : undefined + } + + if (parent?.type === 'CallExpression') { + const variableDeclarator = _nodeParent({ node: parent }) + + if (variableDeclarator?.type === 'VariableDeclarator') { + const name = _nodeName({ + node: _nodeField({ field: 'id', node: variableDeclarator }), + }) + + return _isComponentName({ name }) ? name : undefined + } + } + + return undefined +} + +const _owningComponent = ({ node }: { node: Node }) => { + let current = _nodeParent({ node }) + + while (current !== undefined) { + if (_isFunction({ node: current })) { + const name = _componentName({ node: current }) + + return name === undefined ? undefined : { name, node: current } + } + + current = _nodeParent({ node: current }) + } + + return undefined +} + +const _outerExpression = ({ node }: { node: Node }) => { + let current = node + let parent = _nodeParent({ node: current }) + + while ( + parent !== undefined && + expressionWrapperTypes.has(parent.type) && + _nodeExpression({ node: parent }) === current + ) { + current = parent + parent = _nodeParent({ node: current }) + } + + return current +} + +const _isAssignedValue = ({ node }: { node: Node }) => { + const parent = _nodeParent({ node }) + + return ( + (parent?.type === 'VariableDeclarator' && _nodeField({ field: 'init', node: parent }) === node) || + (parent?.type === 'AssignmentExpression' && _nodeField({ field: 'right', node: parent }) === node) + ) +} + +const _memberPropertyName = ({ node }: { node: Node }) => { + const property = _nodeField({ field: 'property', node }) + + return _isNode(property) && property.type === 'Identifier' ? _nodeName({ node: property }) : undefined +} + +const _isUseCallbackCall = ({ node }: { node: Node }) => { + const callee = _nodeField({ field: 'callee', node }) + + if (!_isNode(callee)) { + return false + } + + return ( + (callee.type === 'Identifier' && _nodeName({ node: callee }) === 'useCallback') || + (callee.type === 'MemberExpression' && _memberPropertyName({ node: callee }) === 'useCallback') + ) +} + +const _isUseCallbackValue = ({ node }: { node: Node }) => { + const expression = _outerExpression({ node }) + const callExpression = _nodeParent({ node: expression }) + const [firstArgument] = callExpression === undefined ? [] : _nodeArray({ field: 'arguments', node: callExpression }) + + if ( + callExpression?.type !== 'CallExpression' || + firstArgument !== expression || + !_isUseCallbackCall({ node: callExpression }) + ) { + return false + } + + return _isAssignedValue({ + node: _outerExpression({ node: callExpression }), + }) +} + +const _isComponentOwnedFunction = ({ node }: { node: Node }) => { + if (node.type === 'FunctionDeclaration') { + return true + } + + const expression = _outerExpression({ node }) + + return _isAssignedValue({ node: expression }) || _isUseCallbackValue({ node }) +} + +const _functionName = ({ node }: { node: Node }) => { + if (node.type === 'FunctionDeclaration') { + return _nodeName({ node: _nodeField({ field: 'id', node }) }) + } + + const expression = _outerExpression({ node }) + const parent = _nodeParent({ node: expression }) + + if (parent?.type === 'VariableDeclarator') { + return _nodeName({ node: _nodeField({ field: 'id', node: parent }) }) + } + + if (parent?.type === 'AssignmentExpression') { + return _nodeName({ node: _nodeField({ field: 'left', node: parent }) }) + } + + if (parent?.type === 'CallExpression' && _isUseCallbackCall({ node: parent })) { + const callbackOwner = _nodeParent({ + node: _outerExpression({ node: parent }), + }) + + return ( + _nodeName({ + node: callbackOwner === undefined ? undefined : _nodeField({ field: 'id', node: callbackOwner }), + }) ?? + _nodeName({ + node: callbackOwner === undefined ? undefined : _nodeField({ field: 'left', node: callbackOwner }), + }) + ) + } + + return undefined +} + +const _message = ({ name }: { name: string | undefined }) => + name === undefined + ? 'Move this component-local function out of the component. If it contains business logic, model it in a state machine or actor instead. Avoid inlining the function into the component body unless no better boundary is possible.' + : `Move "${name}" out of the component. If it contains business logic, model it in a state machine or actor instead. Avoid inlining the function into the component body unless no better boundary is possible.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Move named component-local functions to a state machine, actor, or external helper.', + }, + }, + create(context) { + const filename = context.filename ?? '' + + if (!_isTsxFile({ filename })) { + return {} + } + + const check_ = (node: FunctionNode) => { + if (!_isNode(node)) { + return + } + + if ( + _nodeField({ field: 'body', node }) === undefined || + _owningComponent({ node }) === undefined || + !_isComponentOwnedFunction({ node }) + ) { + return + } + + context.report({ + node, + message: _message({ name: _functionName({ node }) }), + }) + } + + return { + ArrowFunctionExpression: check_, + FunctionDeclaration: check_, + FunctionExpression: check_, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-react-non-component-function-exports.ts b/tools/oxlint/automation/rules/no-react-non-component-function-exports.ts new file mode 100644 index 0000000..f5dff7a --- /dev/null +++ b/tools/oxlint/automation/rules/no-react-non-component-function-exports.ts @@ -0,0 +1,575 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ProgramNode = Parameters>[0] + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +type RangedNode = Node & { + readonly range: [number, number] +} + +type FunctionBinding = { + readonly name: string + readonly node: Node + readonly value: Node +} + +type FunctionExport = { + readonly binding: FunctionBinding | undefined + readonly name: string | undefined + readonly node: RangedNode +} + +interface Options { + readonly frameworkSegmentBasenames?: ReadonlyArray + readonly frameworkSpecialExports?: ReadonlyArray +} + +const functionExpressionTypes = new Set(['ArrowFunctionExpression', 'FunctionExpression']) + +const expressionWrapperTypes = new Set([ + 'ChainExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) + +const componentWrapperNames = new Set(['forwardRef', 'memo']) + +let frameworkSpecialFunctionExports: ReadonlySet = new Set([ + 'generateImageMetadata', + 'generateMetadata', + 'generateSitemaps', + 'generateStaticParams', + 'generateViewport', +]) + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _isRangedNode = (value: unknown): value is RangedNode => + _isNode(value) && + Array.isArray(value.range) && + value.range.length === 2 && + typeof value.range[0] === 'number' && + typeof value.range[1] === 'number' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _nodeArray = ({ field, node }: { field: string; node: Node }) => { + const value = _nodeField({ field, node }) + + return Array.isArray(value) ? value.filter(_isNode) : [] +} + +const _nodeName = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return undefined + } + + const name = _nodeField({ field: 'name', node }) + + return typeof name === 'string' ? name : undefined +} + +const _nodeValue = ({ node }: { node: unknown }) => (_isNode(node) ? _nodeField({ field: 'value', node }) : undefined) + +const _nodeExpression = ({ node }: { node: Node }) => { + const expression = _nodeField({ field: 'expression', node }) + + return _isNode(expression) ? expression : undefined +} + +const _isTsxFile = ({ filename }: { filename: string }) => filename.endsWith('.tsx') + +const _isPascalCase = ({ name }: { name: string | undefined }) => name !== undefined && /^[A-Z]/.test(name) + +let frameworkSegmentBasenames: ReadonlySet = new Set(['page.tsx', 'layout.tsx']) + +const _isFrameworkSegmentFile = ({ filename }: { filename: string }) => + frameworkSegmentBasenames.has(filename.replaceAll('\\', '/').split('/').at(-1) ?? '') + +const _isNextAppRouterSpecialFunctionExport = ({ + exportedFunction, + filename, +}: { + exportedFunction: FunctionExport + filename: string +}) => { + if (!_isFrameworkSegmentFile({ filename })) { + return false + } + + const name = exportedFunction.binding?.name ?? exportedFunction.name + + return name !== undefined && frameworkSpecialFunctionExports.has(name) +} + +const _bindingIdentifierName = ({ node }: { node: unknown }) => + _isNode(node) && node.type === 'Identifier' ? _nodeName({ node }) : undefined + +const _memberPropertyName = ({ node }: { node: Node }) => { + const property = _nodeField({ field: 'property', node }) + const propertyValue = _nodeValue({ node: property }) + + if (_isNode(property) && property.type === 'Identifier') { + return _nodeName({ node: property }) + } + + return typeof propertyValue === 'string' ? propertyValue : undefined +} + +const _innerExpression = ({ node }: { node: Node }) => { + let current = node + let expression = _nodeExpression({ node: current }) + + while (expressionWrapperTypes.has(current.type) && expression !== undefined) { + current = expression + expression = _nodeExpression({ node: current }) + } + + return current +} + +const _isFunctionExpression = ({ node }: { node: Node }) => functionExpressionTypes.has(_innerExpression({ node }).type) + +const _componentWrapperName = ({ node }: { node: Node }) => { + if (node.type === 'Identifier') { + const name = _nodeName({ node }) + + return name !== undefined && componentWrapperNames.has(name) ? name : undefined + } + + if (node.type !== 'MemberExpression') { + return undefined + } + + return _memberPropertyName({ node }) +} + +const _isComponentWrapperCall = ({ node }: { node: Node }) => { + if (node.type !== 'CallExpression') { + return false + } + + const callee = _nodeField({ field: 'callee', node }) + + if (!_isNode(callee)) { + return false + } + + const name = _componentWrapperName({ node: callee }) + + return name !== undefined && componentWrapperNames.has(name) +} + +const _isFunctionValue = ({ + bindings, + node, +}: { + bindings: ReadonlyMap | undefined + node: Node +}): boolean => { + const expression = _innerExpression({ node }) + const name = _nodeName({ node: expression }) + + if (expression.type === 'Identifier' && name !== undefined) { + return bindings?.has(name) ?? false + } + + if (expression.type === 'FunctionDeclaration') { + return true + } + + if (_isFunctionExpression({ node: expression })) { + return true + } + + if (!_isComponentWrapperCall({ node: expression })) { + return false + } + + const [argument] = _nodeArray({ field: 'arguments', node: expression }) + + return argument !== undefined && _isFunctionValue({ bindings, node: argument }) +} + +const _functionBindingFromDeclaration = ({ node }: { node: Node }): FunctionBinding | undefined => { + if (node.type !== 'FunctionDeclaration') { + return undefined + } + + const name = _bindingIdentifierName({ + node: _nodeField({ field: 'id', node }), + }) + + return name === undefined ? undefined : { name, node, value: node } +} + +const _functionBindingFromDeclarator = ({ + bindings, + node, +}: { + bindings: ReadonlyMap | undefined + node: Node +}): FunctionBinding | undefined => { + const name = _bindingIdentifierName({ + node: _nodeField({ field: 'id', node }), + }) + const init = _nodeField({ field: 'init', node }) + + if (name === undefined || !_isNode(init) || !_isFunctionValue({ bindings, node: init })) { + return undefined + } + + return { name, node, value: init } +} + +const _functionBindingFromExpression = ({ + bindings, + node, +}: { + bindings: ReadonlyMap + node: Node +}): FunctionBinding | undefined => { + const expression = _innerExpression({ node }) + const name = _nodeName({ node: expression }) + + if (expression.type === 'Identifier' && name !== undefined) { + return bindings.get(name) + } + + if (!_isComponentWrapperCall({ node: expression })) { + return undefined + } + + const [argument] = _nodeArray({ field: 'arguments', node: expression }) + + return argument === undefined ? undefined : _functionBindingFromExpression({ bindings, node: argument }) +} + +const _functionNameFromExpression = ({ + bindings, + node, +}: { + bindings: ReadonlyMap + node: Node +}): string | undefined => { + const binding = _functionBindingFromExpression({ bindings, node }) + + if (binding !== undefined) { + return binding.name + } + + const expression = _innerExpression({ node }) + + if (expression.type === 'FunctionDeclaration' || expression.type === 'FunctionExpression') { + return _bindingIdentifierName({ + node: _nodeField({ field: 'id', node: expression }), + }) + } + + if (!_isComponentWrapperCall({ node: expression })) { + return undefined + } + + const [argument] = _nodeArray({ field: 'arguments', node: expression }) + + return argument === undefined ? undefined : _functionNameFromExpression({ bindings, node: argument }) +} + +const _addTopLevelBinding = ({ bindings, node }: { bindings: Map; node: Node }) => { + const binding = + node.type === 'FunctionDeclaration' + ? _functionBindingFromDeclaration({ node }) + : node.type === 'VariableDeclarator' + ? _functionBindingFromDeclarator({ bindings, node }) + : undefined + + if (binding === undefined || bindings.has(binding.name)) { + return false + } + + bindings.set(binding.name, binding) + + return true +} + +const _topLevelDeclarations = ({ node }: { node: Node }) => { + const declarations: Array = [] + + for (const statement of _nodeArray({ field: 'body', node })) { + const source = _nodeField({ field: 'source', node: statement }) + const declaration = _nodeField({ field: 'declaration', node: statement }) + + if ( + statement.type === 'ExportNamedDeclaration' && + (source === null || source === undefined) && + _isNode(declaration) + ) { + declarations.push(declaration) + continue + } + + declarations.push(statement) + } + + return declarations +} + +const _addTopLevelDeclarationBindings = ({ + bindings, + node, +}: { + bindings: Map + node: Node +}) => { + if (node.type === 'FunctionDeclaration') { + return _addTopLevelBinding({ bindings, node }) + } + + if (node.type !== 'VariableDeclaration') { + return false + } + + let added = false + + for (const declaration of _nodeArray({ field: 'declarations', node })) { + if (_addTopLevelBinding({ bindings, node: declaration })) { + added = true + } + } + + return added +} + +const _topLevelFunctionBindings = ({ node }: { node: Node }) => { + const bindings = new Map() + const declarations = _topLevelDeclarations({ node }) + let added = true + + while (added) { + added = false + + for (const declaration of declarations) { + if (_addTopLevelDeclarationBindings({ bindings, node: declaration })) { + added = true + } + } + } + + return bindings +} + +const _functionExport = ({ + binding, + name, + node, +}: { + binding: FunctionBinding | undefined + name: string | undefined + node: Node +}) => (_isRangedNode(node) ? [{ binding, name, node }] : []) + +const _exportsFromDeclaration = ({ + bindings, + declaration, + node, +}: { + bindings: ReadonlyMap + declaration: Node | undefined + node: Node +}) => { + if (declaration?.type === 'FunctionDeclaration') { + const binding = _functionBindingFromDeclaration({ node: declaration }) + + return binding === undefined ? [] : _functionExport({ binding, name: binding.name, node }) + } + + if (declaration?.type !== 'VariableDeclaration') { + return [] + } + + return _nodeArray({ field: 'declarations', node: declaration }).flatMap((variable) => { + const binding = _functionBindingFromDeclarator({ + bindings, + node: variable, + }) + + return binding === undefined ? [] : _functionExport({ binding, name: binding.name, node }) + }) +} + +const _exportsFromSpecifier = ({ bindings, node }: { bindings: ReadonlyMap; node: Node }) => { + const name = _bindingIdentifierName({ + node: _nodeField({ field: 'local', node }), + }) + const binding = name === undefined ? undefined : bindings.get(name) + + return binding === undefined ? [] : _functionExport({ binding, name, node }) +} + +const _exportsFromDefaultDeclaration = ({ + bindings, + node, +}: { + bindings: ReadonlyMap + node: Node +}) => { + const declaration = _nodeField({ field: 'declaration', node }) + + if (!_isNode(declaration)) { + return [] + } + + if (declaration.type === 'Identifier') { + const name = _nodeName({ node: declaration }) + const binding = name === undefined ? undefined : bindings.get(name) + + return binding === undefined ? [] : _functionExport({ binding, name, node }) + } + + if (!_isFunctionValue({ bindings, node: declaration })) { + return [] + } + + const binding = _functionBindingFromExpression({ + bindings, + node: declaration, + }) + const name = binding?.name ?? _functionNameFromExpression({ bindings, node: declaration }) + + return _functionExport({ binding, name, node }) +} + +const _functionExports = ({ node }: { node: Node }) => { + const bindings = _topLevelFunctionBindings({ node }) + const exports: Array = [] + + for (const statement of _nodeArray({ field: 'body', node })) { + if (statement.type === 'ExportDefaultDeclaration') { + exports.push(..._exportsFromDefaultDeclaration({ bindings, node: statement })) + continue + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue + } + + const source = _nodeField({ field: 'source', node: statement }) + + if (source !== null && source !== undefined) { + continue + } + + const declaration = _nodeField({ field: 'declaration', node: statement }) + + exports.push( + ..._exportsFromDeclaration({ + bindings, + declaration: _isNode(declaration) ? declaration : undefined, + node: statement, + }), + ) + + for (const specifier of _nodeArray({ + field: 'specifiers', + node: statement, + })) { + exports.push(..._exportsFromSpecifier({ bindings, node: specifier })) + } + } + + return exports +} + +const _componentName = ({ exportedFunction }: { exportedFunction: FunctionExport }) => + exportedFunction.binding?.name ?? exportedFunction.name + +const _isComponentExport = ({ exportedFunction }: { exportedFunction: FunctionExport }) => + _isPascalCase({ name: _componentName({ exportedFunction }) }) + +const _message = ({ name }: { name: string | undefined }) => + name === undefined + ? 'Move this exported function out of the TSX file. TSX files should export components only.' + : `Move "${name}" out of the TSX file. TSX files should export components only.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Export only components from TSX files; move other exported functions to a non-TSX module.', + }, + schema: [ + { + type: 'object', + properties: { + frameworkSegmentBasenames: { + type: 'array', + items: { type: 'string' }, + }, + frameworkSpecialExports: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + frameworkSegmentBasenames = new Set(options.frameworkSegmentBasenames ?? ['page.tsx', 'layout.tsx']) + frameworkSpecialFunctionExports = new Set( + options.frameworkSpecialExports ?? [ + 'generateImageMetadata', + 'generateMetadata', + 'generateSitemaps', + 'generateStaticParams', + 'generateViewport', + ], + ) + const filename = context.filename ?? '' + + if (!_isTsxFile({ filename })) { + return {} + } + + return { + Program(node: ProgramNode) { + if (!_isNode(node)) { + return + } + + for (const exportedFunction of _functionExports({ node })) { + if ( + _isComponentExport({ exportedFunction }) || + _isNextAppRouterSpecialFunctionExport({ + exportedFunction, + filename, + }) + ) { + continue + } + + context.report({ + node: exportedFunction.node, + message: _message({ name: _componentName({ exportedFunction }) }), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-react-state-hooks.ts b/tools/oxlint/automation/rules/no-react-state-hooks.ts new file mode 100644 index 0000000..1d8728f --- /dev/null +++ b/tools/oxlint/automation/rules/no-react-state-hooks.ts @@ -0,0 +1,62 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const bannedHooks = new Set(['useEffect', 'useReducer', 'useState']) + +const message = (hookName: string) => `${hookName} is banned. Use xstate actors instead.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid React state and effect hooks; model component behavior with XState actors.', + }, + }, + create(context) { + const bannedImportedNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'react') return + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier' || specifier.imported.type !== 'Identifier') { + continue + } + + const importedName = specifier.imported.name + const localName = specifier.local.name + + if (importedName !== undefined && localName !== undefined && bannedHooks.has(importedName)) { + bannedImportedNames.add(localName) + } + } + }, + CallExpression(node) { + const callee = node.callee + + if ( + callee.type === 'Identifier' && + (bannedImportedNames.has(callee.name) || bannedHooks.has(callee.name)) + ) { + context.report({ + node: callee, + message: message(callee.name), + }) + } else if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + bannedHooks.has(callee.property.name) + ) { + context.report({ + node: callee, + message: message(callee.property.name), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-reexport-only-modules.ts b/tools/oxlint/automation/rules/no-reexport-only-modules.ts new file mode 100644 index 0000000..0e88dd2 --- /dev/null +++ b/tools/oxlint/automation/rules/no-reexport-only-modules.ts @@ -0,0 +1,72 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly allowedFilenames?: ReadonlyArray + readonly routeDirectoryNames?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow modules whose only responsibility is re-exporting other modules.', + }, + schema: [ + { + type: 'object', + properties: { + allowedFilenames: { + type: 'array', + items: { type: 'string' }, + }, + routeDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const allowedFilenames = new Set(options.allowedFilenames ?? ['loading.tsx', 'not-found.tsx']) + const routeDirectoryNames = new Set(options.routeDirectoryNames ?? ['app']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const segments = filename.split('/') + const basename = segments.at(-1) ?? '' + + if (segments.some((segment) => routeDirectoryNames.has(segment)) && allowedFilenames.has(basename)) { + return {} + } + + return { + Program(node) { + const statements = node.body.filter( + (statement) => statement.type !== 'ExpressionStatement' || typeof statement.directive !== 'string', + ) + + if ( + statements.length === 0 || + !statements.every( + (statement) => + statement.type === 'ExportAllDeclaration' || + (statement.type === 'ExportNamedDeclaration' && statement.source !== null), + ) + ) { + return + } + + context.report({ + node, + message: + 'Do not create re-export-only modules. Import from the owning module directly or add this intentional public entrypoint as an exact lint override.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-single-use-private-functions.ts b/tools/oxlint/automation/rules/no-single-use-private-functions.ts new file mode 100644 index 0000000..bd0a116 --- /dev/null +++ b/tools/oxlint/automation/rules/no-single-use-private-functions.ts @@ -0,0 +1,359 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + arguments?: unknown + body?: unknown + callee?: unknown + declaration?: unknown + declarations?: unknown + expression?: unknown + id?: unknown + init?: unknown + local?: unknown + name?: unknown + object?: unknown + parent?: Node | null + property?: unknown + source?: unknown + specifiers?: unknown + type: string + value?: unknown +} + +type CandidateKind = 'effect-function' | 'effect-program' | 'function' | 'type' + +type ReferenceLike = { + identifier: Node + isRead: () => boolean +} + +const functionExpressionTypes = new Set(['ArrowFunctionExpression', 'FunctionExpression']) + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const nodeList = ({ value }: { value: unknown }) => (Array.isArray(value) ? value.filter(isNode) : []) + +const nodeField = ({ field, node }: { field: keyof Node; node: Node }) => + isNode(node[field]) ? node[field] : undefined + +const isFunctionExpression = ({ node }: { node: unknown }) => isNode(node) && functionExpressionTypes.has(node.type) + +const isPascalCase = ({ name }: { name: string }) => /^[A-Z]/.test(name) + +const memberPropertyName = ({ node }: { node: Node }) => { + const property = nodeField({ field: 'property', node }) + + if (property?.type === 'Identifier' && typeof property.name === 'string') { + return property.name + } + + if (typeof property?.value === 'string') { + return property.value + } + + return undefined +} + +const effectCalleeName = ({ node }: { node: Node }) => { + const object = nodeField({ field: 'object', node }) + + if (node.type !== 'MemberExpression' || object?.type !== 'Identifier' || object.name !== 'Effect') { + return undefined + } + + return memberPropertyName({ node }) +} + +const isEffectCall = ({ name, node }: { name: 'fn' | 'fnUntraced' | 'gen'; node: unknown }) => + isNode(node) && + node.type === 'CallExpression' && + effectCalleeName({ node: nodeField({ field: 'callee', node }) ?? node }) === name + +const isEffectFunctionInitializer = ({ node }: { node: unknown }) => + isEffectCall({ name: 'fnUntraced', node }) || + isEffectCall({ name: 'fn', node }) || + (isNode(node) && + node.type === 'CallExpression' && + isEffectCall({ name: 'fn', node: nodeField({ field: 'callee', node }) })) + +const isEffectProgramInitializer = ({ node }: { node: unknown }) => isEffectCall({ name: 'gen', node }) + +const topLevelVariableKind = ({ node }: { node: Node }): CandidateKind | undefined => { + const id = nodeField({ field: 'id', node }) + + if ( + id?.type !== 'Identifier' || + node.parent?.type !== 'VariableDeclaration' || + node.parent.parent?.type !== 'Program' + ) { + return undefined + } + + if (isFunctionExpression({ node: node.init })) { + return 'function' + } + + if (isEffectFunctionInitializer({ node: node.init })) { + return 'effect-function' + } + + if (isEffectProgramInitializer({ node: node.init })) { + return 'effect-program' + } + + return undefined +} + +const addIdentifierName = ({ names, node }: { names: Set; node: unknown }) => { + if (isNode(node) && node.type === 'Identifier' && typeof node.name === 'string') { + names.add(node.name) + } +} + +const addDeclarationNames = ({ declaration, names }: { declaration: unknown; names: Set }) => { + if (!isNode(declaration)) { + return + } + + if (declaration.type === 'FunctionDeclaration') { + addIdentifierName({ + names, + node: nodeField({ field: 'id', node: declaration }), + }) + return + } + + if (declaration.type === 'VariableDeclaration') { + for (const variable of nodeList({ value: declaration.declarations })) { + addIdentifierName({ + names, + node: nodeField({ field: 'id', node: variable }), + }) + } + } + + if (declaration.type === 'TSTypeAliasDeclaration' || declaration.type === 'TSInterfaceDeclaration') { + addIdentifierName({ + names, + node: nodeField({ field: 'id', node: declaration }), + }) + } +} + +const exportedNames = ({ node }: { node: Node }) => { + const names = new Set() + + for (const statement of nodeList({ value: node.body })) { + if (statement.type === 'ExportDefaultDeclaration') { + const declaration = nodeField({ field: 'declaration', node: statement }) + + addIdentifierName({ names, node: declaration }) + addIdentifierName({ + names, + node: declaration === undefined ? undefined : nodeField({ field: 'id', node: declaration }), + }) + } + + if (statement.type === 'ExportNamedDeclaration') { + addDeclarationNames({ declaration: statement.declaration, names }) + + if (statement.source === null || statement.source === undefined) { + for (const specifier of nodeList({ value: statement.specifiers })) { + addIdentifierName({ + names, + node: nodeField({ field: 'local', node: specifier }), + }) + } + } + } + + if (statement.type === 'TSExportAssignment') { + addIdentifierName({ names, node: statement.expression }) + } + } + + return names +} + +const hasTypeAncestor = ({ node }: { node: Node }) => { + let current = node.parent + + while (current !== undefined && current !== null) { + if (current.type.startsWith('TS')) { + return true + } + + if (current.type === 'Program' || current.type === 'BlockStatement' || current.type === 'ExpressionStatement') { + return false + } + + current = current.parent + } + + return false +} + +const hasExportAncestor = ({ node }: { node: Node }) => { + let current = node.parent + + while (current !== undefined && current !== null) { + if ( + current.type === 'ExportDefaultDeclaration' || + current.type === 'ExportSpecifier' || + current.type === 'TSExportAssignment' + ) { + return true + } + + if (current.type === 'Program' || current.type === 'BlockStatement') { + return false + } + + current = current.parent + } + + return false +} + +const isRuntimeReadReference = ({ reference }: { reference: ReferenceLike }) => + reference.isRead() && + !hasTypeAncestor({ node: reference.identifier }) && + !hasExportAncestor({ node: reference.identifier }) + +const runtimeReadCount = ({ references }: { references: Array }) => + references.filter((reference) => isRuntimeReadReference({ reference })).length + +const isTypeReadReference = ({ reference }: { reference: ReferenceLike }) => + reference.isRead() && + hasTypeAncestor({ node: reference.identifier }) && + !hasExportAncestor({ node: reference.identifier }) + +const typeReadCount = ({ references }: { references: Array }) => + references.filter((reference) => isTypeReadReference({ reference })).length + +const singleUseCount = ({ + candidate, +}: { + candidate: { + kind: CandidateKind + variable: { + references: Array + } + } +}) => + candidate.kind === 'type' + ? typeReadCount({ references: candidate.variable.references }) + : runtimeReadCount({ references: candidate.variable.references }) + +const message = ({ kind, name }: { kind: CandidateKind; name: string }) => { + if (kind === 'effect-function') { + return `Inline the private Effect function "${name}" at its only usage site instead of defining it as a top-level function.` + } + + if (kind === 'effect-program') { + return `Inline the private Effect program "${name}" at its only usage site instead of defining it as a top-level value.` + } + + if (kind === 'type') { + return `Inline the private type "${name}" at its only usage site instead of defining it as a top-level type.` + } + + return `Inline the private function "${name}" at its only usage site instead of defining it as a top-level function.` +} + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Inline private top-level functions, Effect values, and types referenced only once.', + }, + }, + create(context) { + return { + Program(node) { + type SourceNode = Parameters[0] + type ScopeVariable = ReturnType[number] + type Candidate = { + kind: CandidateKind + name: string + node: SourceNode + variable: ScopeVariable + } + + const exported = exportedNames({ node }) + const candidates = node.body.flatMap((statement): Array => { + if (statement.type === 'FunctionDeclaration') { + const name = statement.id?.name + + if (name === undefined || isPascalCase({ name }) || exported.has(name)) { + return [] + } + + const variable = context.sourceCode + .getDeclaredVariables(statement) + .find((item) => item.name === name) + + return variable === undefined ? [] : [{ kind: 'function', name, node: statement, variable }] + } + + if (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') { + const name = statement.id.name + + if (exported.has(name)) { + return [] + } + + const variable = context.sourceCode + .getDeclaredVariables(statement) + .find((item) => item.name === name) + + return variable === undefined ? [] : [{ kind: 'type', name, node: statement, variable }] + } + + if (statement.type === 'VariableDeclaration') { + return statement.declarations.flatMap((declaration): Array => { + const name = declaration.id.type === 'Identifier' ? declaration.id.name : undefined + const kind = topLevelVariableKind({ node: declaration }) + + if ( + name === undefined || + kind === undefined || + isPascalCase({ name }) || + exported.has(name) + ) { + return [] + } + + const variable = context.sourceCode + .getDeclaredVariables(declaration) + .find((item) => item.name === name) + + return variable === undefined ? [] : [{ kind, name, node: declaration, variable }] + }) + } + + return [] + }) + + for (const candidate of candidates) { + if (singleUseCount({ candidate }) !== 1) { + continue + } + + context.report({ + node: candidate.node, + message: message({ + kind: candidate.kind, + name: candidate.name, + }), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-single-use-xstate-actions.ts b/tools/oxlint/automation/rules/no-single-use-xstate-actions.ts new file mode 100644 index 0000000..9c32f97 --- /dev/null +++ b/tools/oxlint/automation/rules/no-single-use-xstate-actions.ts @@ -0,0 +1,105 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +import { + collectSetupDefinitions, + countMachineReferences, + isNode, + nodeAt, + nodeList, + objectProperty, + propertyValue, + setupCallFromCreateMachineCall, + stringLiteralValue, +} from './xstate-single-use.ts' + +type Rule = Parameters[1] + +export const actionReferenceNames = ({ node }: { node: { type: string } }): Array => { + if (!isNode(node)) { + return [] + } + + const literalValue = stringLiteralValue({ node }) + + if (literalValue !== undefined) { + return [literalValue] + } + + if (node.type === 'ArrayExpression') { + return nodeList({ value: node.elements }).flatMap((element) => actionReferenceNames({ node: element })) + } + + const typeProperty = objectProperty({ name: 'type', node }) + const typeValue = typeProperty === undefined ? undefined : propertyValue({ node: typeProperty }) + + if (typeValue === undefined) { + return [] + } + + const reference = stringLiteralValue({ node: typeValue }) + + return reference === undefined ? [] : [reference] +} + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Inline custom XState setup actions referenced only once in the machine config.', + }, + }, + create(context) { + return { + CallExpression(node) { + if (!isNode(node)) { + return + } + + const setupCall = setupCallFromCreateMachineCall({ node }) + + if (setupCall === undefined) { + return + } + + const definitions = collectSetupDefinitions({ + definitionPropertyName: 'actions', + node: setupCall, + }) + + if (definitions.length === 0) { + return + } + + const machineConfig = nodeAt({ index: 0, value: node.arguments }) + + if (machineConfig === undefined) { + return + } + + const definedNames = new Set(definitions.map((definition) => definition.name)) + const counts = new Map() + + countMachineReferences({ + counts, + definedNames, + node: machineConfig, + referenceNames: actionReferenceNames, + referencePropertyName: 'actions', + }) + + for (const definition of definitions) { + if ((counts.get(definition.name) ?? 0) !== 1) { + continue + } + + context.report({ + node: definition.node, + message: `Inline the "${definition.name}" XState action at its only usage site instead of defining it in setup actions.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-single-use-xstate-guards.ts b/tools/oxlint/automation/rules/no-single-use-xstate-guards.ts new file mode 100644 index 0000000..d31a5c3 --- /dev/null +++ b/tools/oxlint/automation/rules/no-single-use-xstate-guards.ts @@ -0,0 +1,79 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +import { + collectSetupDefinitions, + countMachineReferences, + isNode, + nodeAt, + setupCallFromCreateMachineCall, + typedReferenceName, +} from './xstate-single-use.ts' + +type Rule = Parameters[1] + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Inline custom XState setup guards referenced only once in the machine config.', + }, + }, + create(context) { + return { + CallExpression(node) { + if (!isNode(node)) { + return + } + + const setupCall = setupCallFromCreateMachineCall({ node }) + + if (setupCall === undefined) { + return + } + + const definitions = collectSetupDefinitions({ + definitionPropertyName: 'guards', + node: setupCall, + }) + + if (definitions.length === 0) { + return + } + + const machineConfig = nodeAt({ index: 0, value: node.arguments }) + + if (machineConfig === undefined) { + return + } + + const definedNames = new Set(definitions.map((definition) => definition.name)) + const counts = new Map() + + countMachineReferences({ + counts, + definedNames, + node: machineConfig, + referenceNames: ({ node: guard }) => { + const reference = typedReferenceName({ node: guard }) + + return reference === undefined ? [] : [reference] + }, + referencePropertyName: 'guard', + }) + + for (const definition of definitions) { + if ((counts.get(definition.name) ?? 0) !== 1) { + continue + } + + context.report({ + node: definition.node, + message: `Inline the "${definition.name}" XState guard at its only usage site instead of defining it in setup guards.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-sql-type-parameter.ts b/tools/oxlint/automation/rules/no-sql-type-parameter.ts new file mode 100644 index 0000000..48f77d9 --- /dev/null +++ b/tools/oxlint/automation/rules/no-sql-type-parameter.ts @@ -0,0 +1,36 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid sql templates; use typed query APIs or validated schemas.', + }, + }, + create(context) { + return { + TaggedTemplateExpression(node) { + const isSqlTag = + (node.tag.type === 'Identifier' && node.tag.name === 'sql') || + (node.tag.type === 'MemberExpression' && + node.tag.property.type === 'Identifier' && + node.tag.property.name === 'sql') + const hasTypeParameters = + (node.typeArguments !== undefined && node.typeArguments !== null) || + ('typeParameters' in node && node.typeParameters !== undefined && node.typeParameters !== null) + + if (isSqlTag && hasTypeParameters) { + context.report({ + node, + message: + 'Do not use sql templates. Use typed Drizzle/D1 query APIs or validated schemas instead.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts b/tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts new file mode 100644 index 0000000..88f0899 --- /dev/null +++ b/tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts @@ -0,0 +1,297 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type CallExpressionNode = Parameters>[0] +type ImportDeclarationNode = Parameters>[0] +type IdentifierNode = Parameters>[0] +type CallbackExpression = { + expression: unknown + parameterName: string | undefined +} + +const message = + 'Do not expose an Effect service method through a static forwarder. Yield the service at the usage site and call the method directly.' + +const _nodeName = ({ node }: { node: unknown }) => + typeof node === 'object' && node !== null && 'name' in node && typeof node.name === 'string' ? node.name : undefined + +const _isCallExpressionNode = (node: unknown): node is CallExpressionNode => + typeof node === 'object' && node !== null && 'type' in node && node.type === 'CallExpression' + +const _isIdentifierNode = (node: unknown): node is IdentifierNode => + typeof node === 'object' && node !== null && 'type' in node && node.type === 'Identifier' + +const _importedName = ({ specifier }: { specifier: ImportDeclarationNode['specifiers'][number] }) => + specifier.type === 'ImportSpecifier' ? _nodeName({ node: specifier.imported }) : undefined + +const _isStaticClassField = ({ node }: { node: CallExpressionNode }) => { + let current: unknown = node.parent + + while (typeof current === 'object' && current !== null && 'type' in current) { + if (current.type === 'PropertyDefinition') { + return 'static' in current && current.static === true + } + + if (current.type === 'Program') { + return false + } + + current = 'parent' in current ? current.parent : undefined + } + + return false +} + +const _callbackExpression = ({ node }: { node: unknown }): CallbackExpression | undefined => { + if (typeof node !== 'object' || node === null || !('type' in node)) { + return undefined + } + + if (node.type !== 'ArrowFunctionExpression' && node.type !== 'FunctionExpression') { + return undefined + } + + if (!('params' in node) || !Array.isArray(node.params)) { + return undefined + } + + const [parameter] = node.params + if ( + node.params.length !== 1 || + typeof parameter !== 'object' || + parameter === null || + !('type' in parameter) || + parameter.type !== 'Identifier' + ) { + return undefined + } + + if (!('body' in node) || typeof node.body !== 'object' || node.body === null) { + return undefined + } + + if (!('type' in node.body) || node.body.type !== 'BlockStatement') { + return { + expression: node.body, + parameterName: _nodeName({ node: parameter }), + } + } + + if (!('body' in node.body) || !Array.isArray(node.body.body)) { + return undefined + } + + const [statement] = node.body.body + if ( + node.body.body.length !== 1 || + typeof statement !== 'object' || + statement === null || + !('type' in statement) || + statement.type !== 'ReturnStatement' || + !('argument' in statement) + ) { + return undefined + } + + return { + expression: statement.argument, + parameterName: _nodeName({ node: parameter }), + } +} + +const _isDirectParameterMember = ({ + expression, + parameterName, +}: { + expression: unknown + parameterName: string | undefined +}) => { + if ( + parameterName === undefined || + typeof expression !== 'object' || + expression === null || + !('type' in expression) + ) { + return false + } + + const member = + expression.type === 'CallExpression' && + 'callee' in expression && + typeof expression.callee === 'object' && + expression.callee !== null + ? expression.callee + : expression + + return ( + 'type' in member && + member.type === 'MemberExpression' && + 'object' in member && + typeof member.object === 'object' && + member.object !== null && + 'type' in member.object && + member.object.type === 'Identifier' && + _nodeName({ node: member.object }) === parameterName + ) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Disallow static Effect service method forwarders; acquire the service where its method is used.', + }, + }, + create(context) { + const effectNamespaceNames = new Set() + const flatMapNames = new Set() + const serviceNames = new Set() + const pipeNames = new Set() + + const isImportedIdentifier = ({ names, node }: { names: ReadonlySet; node: IdentifierNode }) => { + if (!names.has(node.name)) { + return false + } + + let scope: ReturnType | null = context.sourceCode.getScope(node) + + while (scope !== null) { + const variable = scope.set.get(node.name) + if (variable !== undefined) { + return variable.defs.some((definition) => definition.type === 'ImportBinding') + } + + scope = scope.upper + } + + return false + } + + const effectCall = ({ + name, + names, + node, + }: { + name: 'flatMap' | 'service' + names: ReadonlySet + node: unknown + }): CallExpressionNode | undefined => { + if (!_isCallExpressionNode(node)) { + return undefined + } + + if (_isIdentifierNode(node.callee)) { + return isImportedIdentifier({ + names, + node: node.callee, + }) + ? node + : undefined + } + + return node.callee.type === 'MemberExpression' && + _isIdentifierNode(node.callee.object) && + isImportedIdentifier({ + names: effectNamespaceNames, + node: node.callee.object, + }) && + _nodeName({ node: node.callee.property }) === name + ? node + : undefined + } + + const isServiceThisCall = ({ node }: { node: unknown }) => { + const call = effectCall({ name: 'service', names: serviceNames, node }) + return call !== undefined && call.arguments.length === 1 && call.arguments[0]?.type === 'ThisExpression' + } + + const isForwardingFlatMap = ({ node }: { node: unknown }) => { + const call = effectCall({ name: 'flatMap', names: flatMapNames, node }) + if (call === undefined) { + return false + } + + const callback = _callbackExpression({ node: call.arguments.at(-1) }) + return callback !== undefined && _isDirectParameterMember(callback) + } + + const isPipeCall = ({ node }: { node: CallExpressionNode }) => { + if (node.callee.type === 'Identifier') { + return isImportedIdentifier({ + names: pipeNames, + node: node.callee, + }) + } + + return node.callee.type === 'MemberExpression' && _nodeName({ node: node.callee.property }) === 'pipe' + } + + return { + ImportDeclaration(node) { + if (node.source.value === 'effect') { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier' && _importedName({ specifier }) === 'Effect') { + effectNamespaceNames.add(specifier.local.name) + } + + if (specifier.type === 'ImportSpecifier' && _importedName({ specifier }) === 'pipe') { + pipeNames.add(specifier.local.name) + } + } + return + } + + if (node.source.value !== 'effect/Effect') { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + effectNamespaceNames.add(specifier.local.name) + continue + } + + const importedName = _importedName({ specifier }) + if (importedName === 'flatMap') { + flatMapNames.add(specifier.local.name) + } else if (importedName === 'service') { + serviceNames.add(specifier.local.name) + } + } + }, + CallExpression(node) { + if (!_isStaticClassField({ node })) { + return + } + + if ( + effectCall({ name: 'flatMap', names: flatMapNames, node }) !== undefined && + node.arguments.length >= 2 && + isServiceThisCall({ node: node.arguments[0] }) && + isForwardingFlatMap({ node }) + ) { + context.report({ message, node }) + return + } + + if (!isPipeCall({ node })) { + return + } + + const pipedEffect = node.callee.type === 'MemberExpression' ? node.callee.object : node.arguments[0] + const operators = node.callee.type === 'MemberExpression' ? node.arguments : node.arguments.slice(1) + + if ( + isServiceThisCall({ node: pipedEffect }) && + operators.some((operator) => isForwardingFlatMap({ node: operator })) + ) { + context.report({ message, node }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-switch.ts b/tools/oxlint/automation/rules/no-switch.ts new file mode 100644 index 0000000..895726c --- /dev/null +++ b/tools/oxlint/automation/rules/no-switch.ts @@ -0,0 +1,26 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Switch statements are banned. Use Match from effect.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow switch statements. Use Match from effect instead.', + }, + }, + create(context) { + return { + SwitchStatement(node) { + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-try-catch.ts b/tools/oxlint/automation/rules/no-try-catch.ts new file mode 100644 index 0000000..fdef733 --- /dev/null +++ b/tools/oxlint/automation/rules/no-try-catch.ts @@ -0,0 +1,26 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Do not use try/catch. Use Effect.try, Effect.tryPromise, or explicit error channels instead.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid try/catch; use Effect.try, Effect.tryPromise, or explicit error channels.', + }, + }, + create(context) { + return { + TryStatement(node) { + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-type-assertion.ts b/tools/oxlint/automation/rules/no-type-assertion.ts new file mode 100644 index 0000000..66c3a59 --- /dev/null +++ b/tools/oxlint/automation/rules/no-type-assertion.ts @@ -0,0 +1,92 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type NodeLike = { + type: string + key?: NodeLike + name?: unknown + value?: unknown + parent?: NodeLike | null + callee?: NodeLike +} + +const message = 'Avoid type assertions. Prefer typed construction, validation, or inference.' + +const propertyName = (node: NodeLike) => + (node.type === 'Identifier' || node.type === 'PrivateIdentifier') && typeof node.name === 'string' + ? node.name + : node.type === 'StringLiteral' && typeof node.value === 'string' + ? node.value + : undefined + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid TypeScript type assertions; prefer typed construction, validation, or inference.', + }, + }, + create(context) { + return { + TSAsExpression(node) { + if ( + node.typeAnnotation.type === 'TSTypeReference' && + node.typeAnnotation.typeName?.type === 'Identifier' && + node.typeAnnotation.typeName.name === 'const' + ) { + return + } + + const typedProperty = node.parent + const typedKey = + typedProperty?.type === 'Property' && typedProperty.key + ? propertyName(typedProperty.key) + : undefined + const typesProperty = typedProperty?.parent?.parent + const setupCall = typesProperty?.parent?.parent + const property = node.parent?.type === 'TSSatisfiesExpression' ? node.parent.parent : node.parent + const call = property?.parent?.parent + const isSetupTypeAssertion = + typedProperty?.type === 'Property' && + (typedKey === 'input' || + typedKey === 'events' || + typedKey === 'context' || + typedKey === 'children' || + typedKey === 'output') && + typedProperty.parent?.type === 'ObjectExpression' && + typesProperty?.type === 'Property' && + typesProperty.key !== undefined && + propertyName(typesProperty.key) === 'types' && + typesProperty.parent?.type === 'ObjectExpression' && + setupCall?.type === 'CallExpression' && + setupCall.callee?.type === 'Identifier' && + setupCall.callee.name === 'setup' + const isCreateStoreContextAssertion = + property?.type === 'Property' && + property.key !== undefined && + propertyName(property.key) === 'context' && + property.parent?.type === 'ObjectExpression' && + call?.type === 'CallExpression' && + call.callee?.type === 'Identifier' && + call.callee.name === 'createStore' + + if (isSetupTypeAssertion || isCreateStoreContextAssertion) { + return + } + + context.report({ + node, + message, + }) + }, + TSTypeAssertion(node) { + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-typeof-object.ts b/tools/oxlint/automation/rules/no-typeof-object.ts new file mode 100644 index 0000000..bca019e --- /dev/null +++ b/tools/oxlint/automation/rules/no-typeof-object.ts @@ -0,0 +1,43 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const isObjectLiteral = (node: { type: string; value?: unknown }) => node.type === 'Literal' && node.value === 'object' + +const isTypeofExpression = (node: { type: string; operator?: string }) => + node.type === 'UnaryExpression' && node.operator === 'typeof' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid typeof object checks; prefer Effect Schema or explicit null and object validation.', + }, + }, + create(context) { + return { + BinaryExpression(node) { + if ( + !( + (node.operator === '===' || + node.operator === '!==' || + node.operator === '==' || + node.operator === '!=') && + ((isTypeofExpression(node.left) && isObjectLiteral(node.right)) || + (isObjectLiteral(node.left) && isTypeofExpression(node.right))) + ) + ) { + return + } + + context.report({ + node, + message: + 'Do not compare typeof values with "object". First consider whether Effect Schema is a better solution and whether this runtime check is necessary. If it is necessary, use an explicit null and object validation helper instead.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/pipe-max-arguments.ts b/tools/oxlint/automation/rules/pipe-max-arguments.ts new file mode 100644 index 0000000..859183e --- /dev/null +++ b/tools/oxlint/automation/rules/pipe-max-arguments.ts @@ -0,0 +1,33 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const maxPipeArguments = 20 + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Limit pipe calls to 20 arguments; split longer pipelines into named steps.', + }, + }, + create(context) { + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'pipe' && + node.arguments.length > maxPipeArguments + ) { + context.report({ + node, + message: 'This pipe has too many arguments. Split it into smaller named steps.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/private-function-prefix.ts b/tools/oxlint/automation/rules/private-function-prefix.ts new file mode 100644 index 0000000..d72d852 --- /dev/null +++ b/tools/oxlint/automation/rules/private-function-prefix.ts @@ -0,0 +1,58 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type Ranged = { range: [number, number] } + +const functionTypes = new Set(['ArrowFunctionExpression', 'FunctionExpression']) + +const message = 'Private top-level functions must start with an underscore.' + +const _isPascalCase = (name: string) => /^[A-Z]/.test(name) + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require private top-level functions and const function values to start with an underscore.', + }, + }, + create(context) { + const report = (node: Ranged) => { + context.report({ + node, + message, + }) + } + + return { + FunctionDeclaration(node) { + if ( + node.id !== null && + (node.parent?.type === 'Program' || node.parent?.type === 'ExportNamedDeclaration') && + node.parent?.type !== 'ExportNamedDeclaration' && + !_isPascalCase(node.id.name) && + !node.id.name.startsWith('_') + ) { + report(node.id) + } + }, + VariableDeclarator(node) { + if ( + node.id.type !== 'Identifier' || + node.init === null || + !functionTypes.has(node.init.type) || + node.parent?.type !== 'VariableDeclaration' || + node.parent.parent?.type !== 'Program' + ) { + return + } + + if (!_isPascalCase(node.id.name) && !node.id.name.startsWith('_')) { + report(node.id) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-approved-ui-primitives.ts b/tools/oxlint/automation/rules/require-approved-ui-primitives.ts new file mode 100644 index 0000000..ef953e6 --- /dev/null +++ b/tools/oxlint/automation/rules/require-approved-ui-primitives.ts @@ -0,0 +1,86 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly applicationPathMarkers?: ReadonlyArray + readonly bannedElements?: ReadonlyArray + readonly linkModule?: string +} + +const _identifierName = (node: { readonly name?: string; readonly type: string }) => + node.type === 'Identifier' ? node.name : undefined + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require app UI to use approved interactive components and import Link from next/link.', + }, + schema: [ + { + type: 'object', + properties: { + applicationPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + bannedElements: { + type: 'array', + items: { type: 'string' }, + }, + linkModule: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const applicationPathMarkers = options.applicationPathMarkers ?? ['/applications/'] + const bannedIntrinsicElements = new Set(options.bannedElements ?? ['button', 'form']) + const linkModule = options.linkModule ?? 'next/link' + + if (!applicationPathMarkers.some((marker) => filename.includes(marker)) || !filename.endsWith('.tsx')) { + return {} + } + + return { + ImportDeclaration(node) { + if (node.source.value === linkModule) { + return + } + + const importsLink = node.specifiers.some((specifier) => { + if (specifier.type === 'ImportSpecifier') { + return _identifierName(specifier.imported) === 'Link' || specifier.local.name === 'Link' + } + + return specifier.local.name === 'Link' + }) + + if (!importsLink) { + return + } + + context.report({ + node, + message: `Import Link from ${linkModule}.`, + }) + }, + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || !bannedIntrinsicElements.has(node.name.name)) { + return + } + + context.report({ + node: node.name, + message: `Use an approved design-system component instead of <${node.name.name}>.`, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-cn-for-classname-composition.ts b/tools/oxlint/automation/rules/require-cn-for-classname-composition.ts new file mode 100644 index 0000000..3c803d1 --- /dev/null +++ b/tools/oxlint/automation/rules/require-cn-for-classname-composition.ts @@ -0,0 +1,375 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type JSXAttributeNode = Parameters>[0] +type IdentifierNode = Parameters>[0] +type IfStatementNode = Parameters>[0] +type ReturnStatementNode = Parameters>[0] +type ExpressionNode = NonNullable + +interface Options { + readonly ignoredPathMarkers?: ReadonlyArray + readonly utilityModules?: ReadonlyArray + readonly utilityName?: string +} + +const literalExpressionTypes = new Set([ + 'BigIntLiteral', + 'BooleanLiteral', + 'Literal', + 'NullLiteral', + 'NumericLiteral', + 'RegExpLiteral', + 'StringLiteral', +]) + +const message = 'Compose conditional or combined className values with cn from the approved shared utility.' + +const _isClassNameAttribute = ({ node }: { node: JSXAttributeNode }) => + node.name.type === 'JSXIdentifier' && (node.name.name === 'className' || node.name.name.endsWith('ClassName')) + +const _unwrapExpression = ({ node }: { node: ExpressionNode }): ExpressionNode => { + if ( + node.type === 'ChainExpression' || + node.type === 'ParenthesizedExpression' || + node.type === 'TSAsExpression' || + node.type === 'TSInstantiationExpression' || + node.type === 'TSNonNullExpression' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSTypeAssertion' + ) { + return _unwrapExpression({ node: node.expression }) + } + + return node +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require dynamic JSX class name composition to use the approved cn utility.', + }, + schema: [ + { + type: 'object', + properties: { + ignoredPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + utilityModules: { + type: 'array', + items: { type: 'string' }, + }, + utilityName: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const ignoredPathMarkers = options.ignoredPathMarkers ?? ['/native/'] + const utilityModules = options.utilityModules ?? ['@scope/ui-utils'] + const utilityName = options.utilityName ?? 'cn' + + if (!filename.endsWith('.tsx') || ignoredPathMarkers.some((marker) => filename.includes(marker))) { + return {} + } + + const reportedAttributes = new Set() + const conditionalCallbackAttributes = new Set() + const isApprovedCnSource = ({ source }: { source: string }) => utilityModules.includes(source) + + const findVariable = ({ node }: { node: IdentifierNode }) => { + let scope: ReturnType | null = context.sourceCode.getScope(node) + + while (scope !== null) { + const variable = scope.set.get(node.name) + + if (variable !== undefined) { + return variable + } + + scope = scope.upper + } + + return undefined + } + + const findImportDefinition = ({ node }: { node: IdentifierNode }) => + findVariable({ node })?.defs.find( + (definition) => definition.type === 'ImportBinding' && definition.parent?.type === 'ImportDeclaration', + ) + + const isApprovedCnIdentifier = ({ node }: { node: IdentifierNode }) => { + const definition = findImportDefinition({ node }) + + return ( + definition !== undefined && + definition.parent?.type === 'ImportDeclaration' && + isApprovedCnSource({ source: definition.parent.source.value }) && + definition.node.type === 'ImportSpecifier' && + (definition.node.imported.type === 'Identifier' + ? definition.node.imported.name + : definition.node.imported.value) === utilityName + ) + } + + const isNextFontFactory = ({ node }: { node: IdentifierNode }) => { + const definition = findImportDefinition({ node }) + + if (definition === undefined || definition.parent?.type !== 'ImportDeclaration') { + return false + } + + const source = definition.parent.source.value + + return ( + (source === 'next/font/local' && definition.node.type === 'ImportDefaultSpecifier') || + (source === 'next/font/google' && definition.node.type === 'ImportSpecifier') + ) + } + + const isNextFontValue = ({ node }: { node: IdentifierNode }) => { + const variable = findVariable({ node }) + + return ( + variable?.defs.some((definition) => { + if ( + definition.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + definition.node.init?.type !== 'CallExpression' || + definition.node.init.callee.type !== 'Identifier' + ) { + return false + } + + return isNextFontFactory({ + node: definition.node.init.callee, + }) + }) === true + ) + } + + const isNextFontTemplate = ({ node }: { node: Extract }) => + node.expressions.length > 0 && + node.quasis.every((quasi) => quasi.value.raw.trim().length === 0) && + node.expressions.every((expression) => { + const value = _unwrapExpression({ node: expression }) + + return ( + value.type === 'MemberExpression' && + value.computed === false && + value.object.type === 'Identifier' && + value.property.type === 'Identifier' && + (value.property.name === 'className' || value.property.name === 'variable') && + isNextFontValue({ node: value.object }) + ) + }) + + const findVariableDefinition = ({ node }: { node: IdentifierNode }) => + findVariable({ node })?.defs.find( + (definition) => definition.type === 'Variable' && definition.node.type === 'VariableDeclarator', + ) + + const resolveConstInitializer = ({ node }: { node: IdentifierNode }) => { + const definition = findVariableDefinition({ node }) + + if ( + definition?.node.type !== 'VariableDeclarator' || + definition.node.parent?.type !== 'VariableDeclaration' || + definition.node.parent.kind !== 'const' || + definition.node.init === null + ) { + return undefined + } + + return definition.node.init + } + + const isAllowedExpression = ({ + node, + visited, + }: { + node: ExpressionNode + visited: Set + }): boolean => { + const value = _unwrapExpression({ node }) + + if (visited.has(value)) { + return true + } + + visited.add(value) + + if (literalExpressionTypes.has(value.type)) { + return true + } + + if (value.type === 'Identifier') { + const definition = findVariableDefinition({ node: value }) + + if (definition !== undefined) { + const initializer = resolveConstInitializer({ node: value }) + + return initializer !== undefined && isAllowedExpression({ node: initializer, visited }) + } + + return true + } + + if (value.type === 'MemberExpression') { + return true + } + + if (value.type === 'TemplateLiteral') { + return value.expressions.length === 0 || isNextFontTemplate({ node: value }) + } + + if (value.type === 'CallExpression') { + return value.callee.type === 'Identifier' && isApprovedCnIdentifier({ node: value.callee }) + } + + if (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') { + if (value.body === null) { + return false + } + + return value.body.type === 'BlockStatement' ? true : isAllowedExpression({ node: value.body, visited }) + } + + return false + } + + const isCnComposition = ({ + node, + visited, + }: { + node: ExpressionNode + visited: Set + }): boolean => { + const value = _unwrapExpression({ node }) + + if (visited.has(value)) { + return false + } + + visited.add(value) + + if (value.type === 'CallExpression') { + return value.callee.type === 'Identifier' && isApprovedCnIdentifier({ node: value.callee }) + } + + if (value.type !== 'Identifier') { + return false + } + + const initializer = resolveConstInitializer({ node: value }) + + return initializer !== undefined && isCnComposition({ node: initializer, visited }) + } + + const report = ({ node }: { node: JSXAttributeNode }) => { + if (reportedAttributes.has(node.range[0])) { + return + } + + reportedAttributes.add(node.range[0]) + context.report({ + node, + message, + }) + } + + const enclosingClassNameAttribute = ({ node }: { node: IfStatementNode | ReturnStatementNode }) => { + let current: IfStatementNode['parent'] | null = node.parent + + while ( + current !== null && + current.type !== 'ArrowFunctionExpression' && + current.type !== 'FunctionExpression' + ) { + current = current.parent + } + + if ( + current === null || + current.parent?.type !== 'JSXExpressionContainer' || + current.parent.parent?.type !== 'JSXAttribute' || + !_isClassNameAttribute({ node: current.parent.parent }) + ) { + return undefined + } + + return current.parent.parent + } + + return { + IfStatement(node) { + const attribute = enclosingClassNameAttribute({ node }) + + if (attribute !== undefined) { + conditionalCallbackAttributes.add(attribute.range[0]) + } + }, + JSXAttribute(node) { + if ( + !_isClassNameAttribute({ node }) || + node.value?.type !== 'JSXExpressionContainer' || + node.value.expression.type === 'JSXEmptyExpression' + ) { + return + } + + const expression = _unwrapExpression({ + node: node.value.expression, + }) + + if ( + (expression.type === 'ArrowFunctionExpression' || expression.type === 'FunctionExpression') && + expression.body !== null && + expression.body.type === 'BlockStatement' + ) { + return + } + + if ( + !isAllowedExpression({ + node: expression, + visited: new Set(), + }) + ) { + report({ node }) + } + }, + ReturnStatement(node) { + if (node.argument === null) { + return + } + + const enclosingAttribute = enclosingClassNameAttribute({ node }) + + if ( + enclosingAttribute !== undefined && + ((conditionalCallbackAttributes.has(enclosingAttribute.range[0]) && + !isCnComposition({ + node: node.argument, + visited: new Set(), + })) || + !isAllowedExpression({ + node: node.argument, + visited: new Set(), + })) + ) { + report({ node: enclosingAttribute }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-context-service-in-services.ts b/tools/oxlint/automation/rules/require-context-service-in-services.ts new file mode 100644 index 0000000..9307537 --- /dev/null +++ b/tools/oxlint/automation/rules/require-context-service-in-services.ts @@ -0,0 +1,142 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ClassDeclarationNode = Parameters>[0] + +interface Options { + readonly ignoredBasenames?: ReadonlyArray + readonly ignoredDirectoryNames?: ReadonlyArray + readonly serviceDirectoryNames?: ReadonlyArray +} + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _identifierName = ({ node }: { node: unknown }) => + _isNode(node) && node.type === 'Identifier' && typeof _nodeField({ field: 'name', node }) === 'string' + ? _nodeField({ field: 'name', node }) + : undefined + +const _isContextService = ({ node }: { node: unknown }): boolean => { + if (!_isNode(node)) { + return false + } + + if (node.type === 'MemberExpression') { + return ( + _identifierName({ + node: _nodeField({ field: 'object', node }), + }) === 'Context' && + _identifierName({ + node: _nodeField({ field: 'property', node }), + }) === 'Service' + ) + } + + if (node.type === 'CallExpression') { + return _isContextService({ + node: _nodeField({ field: 'callee', node }), + }) + } + + if ( + node.type === 'ChainExpression' || + node.type === 'ParenthesizedExpression' || + node.type === 'TSInstantiationExpression' + ) { + return _isContextService({ + node: _nodeField({ field: 'expression', node }), + }) + } + + return false +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require production modules in services folders to define a class extending Context.Service.', + }, + schema: [ + { + type: 'object', + properties: { + ignoredBasenames: { + type: 'array', + items: { type: 'string' }, + }, + ignoredDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + serviceDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const ignoredBasenames = new Set(options.ignoredBasenames ?? ['index.ts', 'main.ts']) + const ignoredDirectoryNames = new Set(options.ignoredDirectoryNames ?? ['test', 'tests', '__tests__']) + const serviceDirectoryNames = new Set(options.serviceDirectoryNames ?? ['service', 'services']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1) + const segments = filename.split('/') + + if ( + (!filename.endsWith('.ts') && !filename.endsWith('.tsx')) || + filename.endsWith('.d.ts') || + !segments.some((segment) => serviceDirectoryNames.has(segment)) || + segments.some((segment) => ignoredDirectoryNames.has(segment)) || + basename.endsWith('.test.ts') || + basename.endsWith('.test.tsx') || + basename.endsWith('.spec.ts') || + basename.endsWith('.spec.tsx') || + ignoredBasenames.has(basename) + ) { + return {} + } + + let definesContextService = false + + return { + ClassDeclaration(node: ClassDeclarationNode) { + if ( + _isContextService({ + node: node.superClass, + }) + ) { + definesContextService = true + } + }, + 'Program:exit'(node) { + if (definesContextService) { + return + } + + context.report({ + node, + message: 'Files inside services folders must define a class extending Context.Service.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-default-component-export.ts b/tools/oxlint/automation/rules/require-default-component-export.ts new file mode 100644 index 0000000..8e11c45 --- /dev/null +++ b/tools/oxlint/automation/rules/require-default-component-export.ts @@ -0,0 +1,288 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ProgramNode = Parameters>[0] + +interface Options { + readonly applicationPathMarkers?: ReadonlyArray + readonly ignoredFilenamePatterns?: ReadonlyArray + readonly wrapperNames?: ReadonlyArray +} + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +type RangedNode = Node & { + readonly range: [number, number] +} + +let componentWrapperNames: ReadonlySet = new Set(['forwardRef', 'memo']) + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _isRangedNode = (value: unknown): value is RangedNode => + _isNode(value) && + Array.isArray(value.range) && + value.range.length === 2 && + typeof value.range[0] === 'number' && + typeof value.range[1] === 'number' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _nodeArray = ({ field, node }: { field: string; node: Node }) => { + const value = _nodeField({ field, node }) + + return Array.isArray(value) ? value.filter(_isNode) : [] +} + +const _nodeName = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return undefined + } + + const name = _nodeField({ field: 'name', node }) + + return typeof name === 'string' ? name : undefined +} + +const _isPascalCase = ({ name }: { name: string | undefined }) => name !== undefined && /^[A-Z]/.test(name) + +const _isComponentValue = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return false + } + + if ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'ClassExpression' + ) { + return true + } + + if (node.type !== 'CallExpression') { + return false + } + + const callee = _nodeField({ field: 'callee', node }) + + if (!_isNode(callee)) { + return false + } + + const wrapperName = + callee.type === 'Identifier' + ? _nodeName({ node: callee }) + : callee.type === 'MemberExpression' + ? _nodeName({ + node: _nodeField({ field: 'property', node: callee }), + }) + : undefined + + return wrapperName !== undefined && componentWrapperNames.has(wrapperName) +} + +const _declarationComponentNames = ({ node }: { node: Node }) => { + if (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') { + const name = _nodeName({ node: _nodeField({ field: 'id', node }) }) + + return _isPascalCase({ name }) && name !== undefined ? [name] : [] + } + + if (node.type !== 'VariableDeclaration') { + return [] + } + + return _nodeArray({ field: 'declarations', node }).flatMap((declaration) => { + const name = _nodeName({ + node: _nodeField({ field: 'id', node: declaration }), + }) + const value = _nodeField({ field: 'init', node: declaration }) + + return _isPascalCase({ name }) && name !== undefined && _isComponentValue({ node: value }) ? [name] : [] + }) +} + +const _componentBindings = ({ node }: { node: Node }) => { + const names = new Set() + + for (const statement of _nodeArray({ field: 'body', node })) { + const declaration = + statement.type === 'ExportNamedDeclaration' + ? _nodeField({ field: 'declaration', node: statement }) + : statement + + if (!_isNode(declaration)) { + continue + } + + for (const name of _declarationComponentNames({ node: declaration })) { + names.add(name) + } + } + + return names +} + +const _exportedComponentNames = ({ + componentBindings, + node, +}: { + componentBindings: ReadonlySet + node: Node +}) => { + const declaration = _nodeField({ field: 'declaration', node }) + const names = _isNode(declaration) ? _declarationComponentNames({ node: declaration }) : [] + + for (const specifier of _nodeArray({ field: 'specifiers', node })) { + const exportedName = _nodeName({ + node: _nodeField({ field: 'exported', node: specifier }), + }) + const localName = _nodeName({ + node: _nodeField({ field: 'local', node: specifier }), + }) + + if (exportedName !== 'default' && localName !== undefined && componentBindings.has(localName)) { + names.push(localName) + } + } + + return names +} + +const _deferredDefaultComponentNames = ({ + componentBindings, + node, +}: { + componentBindings: ReadonlySet + node: Node +}) => { + if (node.type === 'ExportDefaultDeclaration') { + const name = _nodeName({ + node: _nodeField({ field: 'declaration', node }), + }) + + return name !== undefined && componentBindings.has(name) ? [name] : [] + } + + if (node.type !== 'ExportNamedDeclaration') { + return [] + } + + return _nodeArray({ field: 'specifiers', node }).flatMap((specifier) => { + const exportedName = _nodeName({ + node: _nodeField({ field: 'exported', node: specifier }), + }) + const localName = _nodeName({ + node: _nodeField({ field: 'local', node: specifier }), + }) + + return exportedName === 'default' && localName !== undefined && componentBindings.has(localName) + ? [localName] + : [] + }) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Require app component files to default-export one main component and keep supporting components local.', + }, + schema: [ + { + type: 'object', + properties: { + applicationPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + ignoredFilenamePatterns: { + type: 'array', + items: { type: 'string' }, + }, + wrapperNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const applicationPathMarkers = options.applicationPathMarkers ?? ['/applications/'] + const ignoredFilenamePatterns = options.ignoredFilenamePatterns ?? ['(?:^|-)icons\\.tsx$'] + componentWrapperNames = new Set(options.wrapperNames ?? ['forwardRef', 'memo']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1) + + if ( + !applicationPathMarkers.some((marker) => filename.includes(marker)) || + !filename.endsWith('.tsx') || + ignoredFilenamePatterns.some((pattern) => new RegExp(pattern).test(basename)) + ) { + return {} + } + + return { + Program(node: ProgramNode) { + if (!_isNode(node)) { + return + } + + const componentBindings = _componentBindings({ node }) + + for (const statement of _nodeArray({ field: 'body', node })) { + const deferredDefaultNames = _deferredDefaultComponentNames({ + componentBindings, + node: statement, + }) + + if (deferredDefaultNames.length > 0 && _isRangedNode(statement)) { + context.report({ + node: statement, + message: `Declare ${deferredDefaultNames.join(', ')} with export default directly on the function definition.`, + }) + } + + if ( + statement.type !== 'ExportNamedDeclaration' || + _nodeField({ field: 'source', node: statement }) !== null + ) { + continue + } + + const names = _exportedComponentNames({ + componentBindings, + node: statement, + }) + + if (names.length === 0) { + continue + } + + if (!_isRangedNode(statement)) { + continue + } + + context.report({ + node: statement, + message: `Export one main component as default and keep supporting components local. Named component export${names.length === 1 ? '' : 's'}: ${names.join(', ')}.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts b/tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts new file mode 100644 index 0000000..7f68cf6 --- /dev/null +++ b/tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts @@ -0,0 +1,74 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly allowedTypeScriptFiles?: ReadonlyArray + readonly uiDirectoryNames?: ReadonlyArray +} + +const defaultAllowedTypeScriptFiles = [ + 'apple-icon.ts', + 'icon.ts', + 'manifest.ts', + 'opengraph-image.ts', + 'robots.ts', + 'route.ts', + 'sitemap.ts', + 'twitter-image.ts', +] + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Keep UI modules in app and components folders as TSX; move non-UI modules to their owning lib folder.', + }, + schema: [ + { + type: 'object', + properties: { + allowedTypeScriptFiles: { + type: 'array', + items: { type: 'string' }, + }, + uiDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const allowedTypeScriptFiles = new Set(options.allowedTypeScriptFiles ?? defaultAllowedTypeScriptFiles) + const uiDirectoryNames = new Set(options.uiDirectoryNames ?? ['app', 'components']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1) + const segments = filename.split('/') + + if ( + !filename.endsWith('.ts') || + filename.endsWith('.d.ts') || + allowedTypeScriptFiles.has(basename) || + !segments.some((segment) => uiDirectoryNames.has(segment)) + ) { + return {} + } + + return { + Program(node) { + context.report({ + node, + message: + 'Only TSX files belong in app and components folders. Move this non-UI module to its owning lib folder.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-xstate-event-satisfies.ts b/tools/oxlint/automation/rules/require-xstate-event-satisfies.ts new file mode 100644 index 0000000..7649195 --- /dev/null +++ b/tools/oxlint/automation/rules/require-xstate-event-satisfies.ts @@ -0,0 +1,159 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + argument?: unknown + arguments?: Array + body?: unknown + callee?: unknown + expression?: unknown + name?: unknown + range: [number, number] + type: string +} + +const message = 'XState sent object events must use satisfies with the target event type.' + +const unwrapExpression = ({ node }: { node: Node }): Node => { + if ( + node.type === 'ParenthesizedExpression' || + node.type === 'ChainExpression' || + node.type === 'TSNonNullExpression' + ) { + return !isNode(node.expression) ? node : unwrapExpression({ node: node.expression }) + } + + return node +} + +const isNode = (value: unknown): value is Node => { + if (typeof value !== 'object' || value === null) { + return false + } + + return 'type' in value && typeof value.type === 'string' && 'range' in value && Array.isArray(value.range) +} + +const nodeList = ({ value }: { value: unknown }): Array => (Array.isArray(value) ? value.filter(isNode) : []) + +const isSatisfiedObjectEvent = ({ node }: { node: Node }) => { + const expression = unwrapExpression({ node }) + + return ( + expression.type === 'TSSatisfiesExpression' && + isNode(expression.expression) && + unwrapExpression({ node: expression.expression }).type === 'ObjectExpression' + ) +} + +const isBareObjectEvent = ({ node }: { node: Node }) => unwrapExpression({ node }).type === 'ObjectExpression' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require XState sent object events to use a satisfies event type.', + }, + }, + create(context) { + const collectEventObjectsMissingSatisfies = ({ node }: { node: Node }): Array => { + const eventFactory = unwrapExpression({ node }) + + if (isSatisfiedObjectEvent({ node: eventFactory })) { + return [] + } + + if (isBareObjectEvent({ node: eventFactory })) { + return [eventFactory] + } + + if (eventFactory.type === 'ArrowFunctionExpression' || eventFactory.type === 'FunctionExpression') { + if (eventFactory.body === undefined) { + return [] + } + + if ( + !Array.isArray(eventFactory.body) && + isNode(eventFactory.body) && + eventFactory.body.type === 'BlockStatement' && + Array.isArray(eventFactory.body.body) + ) { + return nodeList({ value: eventFactory.body.body }).flatMap((statement) => { + if (statement.type !== 'ReturnStatement' || !isNode(statement.argument)) { + return [] + } + + const argument = unwrapExpression({ node: statement.argument }) + + if (isSatisfiedObjectEvent({ node: argument })) { + return [] + } + + return isBareObjectEvent({ node: argument }) ? [argument] : [] + }) + } + + if (!Array.isArray(eventFactory.body) && isNode(eventFactory.body)) { + const body = unwrapExpression({ node: eventFactory.body }) + + if (isSatisfiedObjectEvent({ node: body })) { + return [] + } + + return isBareObjectEvent({ node: body }) ? [body] : [] + } + + return nodeList({ value: eventFactory.body }).flatMap((statement) => { + if (statement.type !== 'ReturnStatement' || !isNode(statement.argument)) { + return [] + } + + const argument = unwrapExpression({ node: statement.argument }) + + if (isSatisfiedObjectEvent({ node: argument })) { + return [] + } + + return isBareObjectEvent({ node: argument }) ? [argument] : [] + }) + } + + return [] + } + + return { + CallExpression(node) { + const eventArgumentIndex = + isNode(node.callee) && node.callee.type === 'Identifier' + ? node.callee.name === 'sendParent' + ? 0 + : node.callee.name === 'sendTo' + ? 1 + : undefined + : undefined + + if (eventArgumentIndex === undefined) { + return + } + + const eventArgument = node.arguments.at(eventArgumentIndex) + + if (!isNode(eventArgument)) { + return + } + + for (const eventObject of collectEventObjectsMissingSatisfies({ + node: eventArgument, + })) { + context.report({ + node: eventObject, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/xstate-single-use.ts b/tools/oxlint/automation/rules/xstate-single-use.ts new file mode 100644 index 0000000..5707209 --- /dev/null +++ b/tools/oxlint/automation/rules/xstate-single-use.ts @@ -0,0 +1,184 @@ +type Node = { + [key: string]: unknown + type: string +} + +type ReportNode = Node & { range: [number, number] } + +export type Definition = { + name: string + node: ReportNode +} + +export type ReferenceNames = ({ node }: { node: Node }) => Array + +export const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +export const isReportNode = (value: unknown): value is ReportNode => + isNode(value) && + 'range' in value && + Array.isArray(value.range) && + value.range.length === 2 && + typeof value.range[0] === 'number' && + typeof value.range[1] === 'number' + +export const nodeList = ({ value }: { value: unknown }) => (Array.isArray(value) ? value.filter(isNode) : []) + +export const nodeAt = ({ index, value }: { index: number; value: unknown }) => + Array.isArray(value) && isNode(value[index]) ? value[index] : undefined + +const nodeField = ({ field, node }: { field: string; node: Node }) => (isNode(node[field]) ? node[field] : undefined) + +export const propertyName = ({ node }: { node: Node }) => { + const key = nodeField({ field: 'key', node }) + + if (key?.type === 'Identifier' && typeof key.name === 'string') { + return key.name + } + + if (typeof key?.value === 'string') { + return key.value + } + + const property = nodeField({ field: 'property', node }) + + if (property?.type === 'Identifier' && typeof property.name === 'string') { + return property.name + } + + if (typeof property?.value === 'string') { + return property.value + } + + return undefined +} + +export const propertyValue = ({ node }: { node: Node }) => nodeField({ field: 'value', node }) + +export const stringLiteralValue = ({ node }: { node: Node }) => + typeof node.value === 'string' ? node.value : undefined + +export const objectProperty = ({ name, node }: { name: string; node: Node }) => { + if (node.type !== 'ObjectExpression') { + return undefined + } + + return nodeList({ value: node.properties }).find((property) => propertyName({ node: property }) === name) +} + +export const collectSetupDefinitions = ({ + definitionPropertyName, + node, +}: { + definitionPropertyName: string + node: Node +}) => { + const setupOptions = nodeAt({ index: 0, value: node.arguments }) + + if (setupOptions === undefined) { + return [] + } + + const definitionsProperty = objectProperty({ + name: definitionPropertyName, + node: setupOptions, + }) + const definitionsObject = + definitionsProperty === undefined ? undefined : propertyValue({ node: definitionsProperty }) + + if (definitionsObject?.type !== 'ObjectExpression') { + return [] + } + + return nodeList({ value: definitionsObject.properties }).flatMap((property): Array => { + const name = propertyName({ node: property }) + + return name !== undefined && isReportNode(property) ? [{ name, node: property }] : [] + }) +} + +export const typedReferenceName = ({ node }: { node: Node }) => { + const literalValue = stringLiteralValue({ node }) + + if (literalValue !== undefined) { + return literalValue + } + + const typeProperty = objectProperty({ name: 'type', node }) + const typeValue = typeProperty === undefined ? undefined : propertyValue({ node: typeProperty }) + + return typeValue === undefined ? undefined : stringLiteralValue({ node: typeValue }) +} + +export const countMachineReferences = ({ + counts, + definedNames, + node, + referenceNames, + referencePropertyName, +}: { + counts: Map + definedNames: Set + node: Node + referenceNames: ReferenceNames + referencePropertyName: string +}) => { + if (node.type === 'ObjectExpression') { + for (const property of nodeList({ value: node.properties })) { + const value = propertyValue({ node: property }) + + if (propertyName({ node: property }) === referencePropertyName) { + if (value !== undefined) { + for (const name of referenceNames({ node: value })) { + if (definedNames.has(name)) { + counts.set(name, (counts.get(name) ?? 0) + 1) + } + } + } + + continue + } + + if (value !== undefined) { + countMachineReferences({ + counts, + definedNames, + node: value, + referenceNames, + referencePropertyName, + }) + } + } + } + + if (node.type === 'ArrayExpression') { + for (const element of nodeList({ value: node.elements })) { + countMachineReferences({ + counts, + definedNames, + node: element, + referenceNames, + referencePropertyName, + }) + } + } +} + +export const setupCallFromCreateMachineCall = ({ node }: { node: Node }) => { + const callee = nodeField({ field: 'callee', node }) + const setupCall = nodeField({ field: 'object', node: callee ?? node }) + const setupCallee = setupCall === undefined ? undefined : nodeField({ field: 'callee', node: setupCall }) + + if ( + callee?.type !== 'MemberExpression' || + propertyName({ node: callee }) !== 'createMachine' || + setupCall?.type !== 'CallExpression' || + setupCallee?.type !== 'Identifier' || + setupCallee.name !== 'setup' + ) { + return undefined + } + + return setupCall +} diff --git a/tools/oxlint/import-extensions.mjs b/tools/oxlint/import-extensions.mjs new file mode 100644 index 0000000..2142b49 --- /dev/null +++ b/tools/oxlint/import-extensions.mjs @@ -0,0 +1,50 @@ +import { defineRule, eslintCompatPlugin } from '@oxlint/plugins' + +const relativeSourceExtension = /^\.{1,2}\/.*\.(?:[cm]?[jt]sx?)(?:[?#].*)?$/u + +export default eslintCompatPlugin({ + meta: { name: 'import-extensions' }, + rules: { + 'no-relative-source-extensions': defineRule({ + meta: { + type: 'problem', + schema: [], + messages: { + extension: 'Use an extensionless relative import instead of a TypeScript or JavaScript extension.', + }, + }, + createOnce(context) { + function check(node) { + if (!node) return + const value = + node.type === 'TemplateLiteral' + ? node.quasis.map((quasi) => quasi.value.cooked ?? '').join('') + : node.value + if (value !== null && value !== undefined && relativeSourceExtension.test(value)) { + context.report({ node, messageId: 'extension' }) + } + } + return { + ImportDeclaration(node) { + check(node.source) + }, + ExportNamedDeclaration(node) { + check(node.source) + }, + ExportAllDeclaration(node) { + check(node.source) + }, + ImportExpression(node) { + check(node.source) + }, + TSImportType(node) { + check(node.source) + }, + TSExternalModuleReference(node) { + check(node.expression) + }, + } + }, + }), + }, +}) diff --git a/tools/typed/README.md b/tools/typed/README.md new file mode 100644 index 0000000..defc716 --- /dev/null +++ b/tools/typed/README.md @@ -0,0 +1,23 @@ +# Type-aware and file rules + +**Agent action: copy the engine or selected rules, then adapt discovery and +configuration.** + +The three `src/rules` checks use the TypeScript Compiler API. The +`src/workspace-rules` area demonstrates a file-policy check for SVG markup. +`src/registry.ts` joins both kinds and `src/engine.ts` discovers projects, +normalizes relative diagnostics, sorts output, and removes duplicates. + +`src/cli.ts` has honest command behavior: errors return `1`, configuration or +usage failures return `2`, and `fix` returns `2` without changing files because +the included rules are diagnostic-only. + +Start from +[`examples/configuration/typed-lint/config.ts`](../../examples/configuration/typed-lint/config.ts). +Replace project globs, exclusions, severities, and file paths with the target +codebase’s ownership model. Invoke the scanner directly from its validation +boundary so every run observes current files. + +Unit tests cover rule behavior and configuration. `test/integration` covers +recursive discovery, sorting, deduplication, configuration failures, exit +statuses, and command execution. diff --git a/tools/typed/src/cli.ts b/tools/typed/src/cli.ts new file mode 100644 index 0000000..86626fa --- /dev/null +++ b/tools/typed/src/cli.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { loadConfig } from './config.js' +import { formatDiagnostic, runTypedLint } from './engine.js' + +type CliOptions = { + command: 'check' | 'fix' + configPath?: string + format: 'json' | 'stylish' + rootDir: string +} + +const usage = `Usage: typed-lint [check|fix] [options] + +Options: + --config Load a configuration file + --format