diff --git a/.changeset/cache-generated-shims.md b/.changeset/cache-generated-shims.md deleted file mode 100644 index e71b1f60..00000000 --- a/.changeset/cache-generated-shims.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@effect/tsgo": patch ---- - -Cache generated TypeScript-Go shims by their effective inputs during local repository setup and CI runs. diff --git a/.changeset/non-interactive-setup.md b/.changeset/non-interactive-setup.md deleted file mode 100644 index 9f44abb3..00000000 --- a/.changeset/non-interactive-setup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@effect/tsgo": minor ---- - -Add a non-interactive `setup` workflow with explicit project, integration, diagnostic, editor, preview, and apply options. Setup now also recommends running the package manager install command whenever it changes `package.json`. diff --git a/.changeset/quiet-trees-flow.md b/.changeset/quiet-trees-flow.md new file mode 100644 index 00000000..ea288cf9 --- /dev/null +++ b/.changeset/quiet-trees-flow.md @@ -0,0 +1,5 @@ +--- +"@effect/tsgo": patch +--- + +Exclude type-only heritage nodes from execution flow graphs across supported TypeScript versions. diff --git a/.github/workflows/update-upstreams.yml b/.github/workflows/update-upstreams.yml index 5990f029..2b813bee 100644 --- a/.github/workflows/update-upstreams.yml +++ b/.github/workflows/update-upstreams.yml @@ -33,6 +33,8 @@ jobs: - name: Update upstream metadata id: upstream + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm exec repoctl upstream update - name: Open metadata update pull request @@ -40,12 +42,13 @@ jobs: if: steps.upstream.outputs.has_changes == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BODY: ${{ steps.upstream.outputs.description }} run: | pnpm exec repoctl github open-pr-if-changed \ --base main \ --head update-upstreams \ --title 'chore: update upstreams' \ - --body 'Automated update of TypeScript-Go upstream component tags.' \ + --body "$PR_BODY" \ --commit-message 'chore: update upstream metadata' \ --check generation_check_id='Generate TypeScript next tag' @@ -85,12 +88,13 @@ jobs: if: steps.upstream.outputs.has_changes == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BODY: ${{ steps.upstream.outputs.description }} run: | pnpm exec repoctl github open-pr-if-changed \ --base main \ --head update-upstreams \ --title 'chore: update upstreams' \ - --body 'Automated update of upstream metadata, generated TypeScript next-tag sources, and Nix inputs.' \ + --body "$PR_BODY" \ --commit-message 'chore: generate TypeScript next tag' \ --check validation_check_id='Validate TypeScript next tag' diff --git a/.github/workflows/validate-oxlint.yml b/.github/workflows/validate-oxlint.yml index 808089e3..679694c5 100644 --- a/.github/workflows/validate-oxlint.yml +++ b/.github/workflows/validate-oxlint.yml @@ -68,6 +68,9 @@ jobs: --version "${{ matrix.oxlint.version }}" --target linux-x64 + - name: Build @effect/tsgo package + run: pnpm --filter @effect/tsgo build + - name: Smoke test Oxlint runtime env: TSGOLINT_PATH: ${{ steps.tsgolint.outputs.artifact_path }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 58d3eda2..3577d61c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -51,5 +51,13 @@ jobs: if: matrix.repoctl run: pnpm run check:repoctl && pnpm run test:repoctl + - name: Install golangci-lint + if: matrix.repoctl + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0 + + - name: Lint + if: matrix.repoctl + run: pnpm lint + - name: Test run: pnpm exec repoctl test diff --git a/README.md b/README.md index 467db20c..c3922582 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The Effect LSP doubles as a tool to perform type-aware linting of Effect code, a Linting can occur either during the `tsc` typecheck phase (with the benefit of running typechecking only once and caching the output), or via a dedicated `npx @effect/tsgo diagnostics --project tsconfig.json` command (with typechecking occurring again), or via the Oxlint Patch. -See the Oxlint Setup guide for instructions on how to install and configure Oxlint with the Effect LSP. +See the Oxlint Setup guide for instructions on how to install and configure Oxlint with the Effect LSP. When running in `tsc` mode, the Effect diagnostics are emitted as standard TypeScript diagnostics, and can be configured to affect the `tsc` exit code through the options `ignoreEffectSuggestionsInTscExitCode`, `ignoreEffectWarningsInTscExitCode`, and `ignoreEffectErrorsInTscExitCode`. @@ -46,104 +46,104 @@ Some diagnostics are off by default or have a default severity of suggestion, bu Correctness Wrong, unsafe, or structurally invalid code patterns. - anyUnknownInErrorContextDetects 'any' or 'unknown' types in Effect error or requirements channels - classSelfMismatchEnsures Self type parameter matches the class name in Context/Service/Tag/Schema classes - duplicatePackageWarns when multiple versions of an Effect-related package are detected in the program - effectFnImplicitAnyMirrors noImplicitAny for unannotated Effect.fn, Effect.fnUntraced, and Effect.fnUntracedEager callback parameters when no outer contextual function type exists. Requires TS's noImplicitAny: true - floatingEffectDetects Effect values that are neither yielded nor assigned - floatingEffectInVitestDetects Effects returned from non-Effect-aware Vitest callbacks - genericEffectServicesPrevents services with type parameters that cannot be discriminated at runtime - missingEffectContextDetects Effect values with unhandled context requirements - missingEffectErrorDetects Effect values with unhandled error types - missingLayerContextDetects Layer values with unhandled context requirements - missingReturnYieldStarSuggests using return yield* for Effects that never succeed - missingStarInYieldEffectGenDetects bare yield (without *) inside Effect generator scopes - nonObjectEffectServiceTypeEnsures Effect.Service types are objects, not primitives - outdatedApiDetects usage of APIs that have been removed or renamed in Effect v4 - overriddenSchemaConstructorPrevents overriding constructors in Schema classes which breaks decoding behavior - promiseInEffectSuccessDetects Promise types in Effect success channels where they are not awaited - schemaLiteralNonFiniteReports statically known non-finite numbers passed to Schema literal constructors - schemaOpaqueInstanceMemberDisallows instance members in classes extending Schema.Opaque + anyUnknownInErrorContextDetects 'any' or 'unknown' types in Effect error or requirements channels + classSelfMismatchEnsures Self type parameter matches the class name in Context/Service/Tag/Schema classes + duplicatePackageWarns when multiple versions of an Effect-related package are detected in the program + effectFnImplicitAnyMirrors noImplicitAny for unannotated Effect.fn, Effect.fnUntraced, and Effect.fnUntracedEager callback parameters when no outer contextual function type exists. Requires TS's noImplicitAny: true + floatingEffectDetects Effect values that are neither yielded nor assigned + floatingEffectInVitestDetects Effects returned from non-Effect-aware Vitest callbacks + genericEffectServicesPrevents services with type parameters that cannot be discriminated at runtime + missingEffectContextDetects Effect values with unhandled context requirements + missingEffectErrorDetects Effect values with unhandled error types + missingLayerContextDetects Layer values with unhandled context requirements + missingReturnYieldStarSuggests using return yield* for Effects that never succeed + missingStarInYieldEffectGenDetects bare yield (without *) inside Effect generator scopes + nonObjectEffectServiceTypeEnsures Effect.Service types are objects, not primitives + outdatedApiDetects usage of APIs that have been removed or renamed in Effect v4 + overriddenSchemaConstructorPrevents overriding constructors in Schema classes which breaks decoding behavior + promiseInEffectSuccessDetects Promise types in Effect success channels where they are not awaited + schemaLiteralNonFiniteReports statically known non-finite numbers passed to Schema literal constructors + schemaOpaqueInstanceMemberDisallows instance members in classes extending Schema.Opaque Anti-pattern Discouraged patterns that often lead to bugs or confusing behavior. - catchUnfailableEffectWarns when using error handling on Effects that never fail - effectFnIifeEffect.fn or Effect.fnUntraced is called as an IIFE; use Effect.gen instead - effectGenUsesAdapterWarns when using the deprecated adapter parameter in Effect.gen - effectInFailureWarns when an Effect is used inside an Effect failure channel - effectInVoidSuccessDetects nested Effects in void success channels that may cause unexecuted effects - globalErrorInEffectCatchWarns when catch callbacks return global Error type instead of typed errors - globalErrorInEffectFailureWarns when the global Error type is used in an Effect failure channel - layerMergeAllWithDependenciesDetects interdependencies in Layer.mergeAll calls where one layer provides a service that another layer requires - lazyEffectSuggests avoiding exported zero-argument functions and service members that lazily return Effect or Stream values - lazyPromiseInEffectSyncWarns when Effect.sync lazily returns a Promise instead of using an async Effect constructor - leakingRequirementsDetects implementation services leaked in service methods - multipleEffectProvideWarns against chaining Effect.provide calls which can cause service lifecycle issues - preferUnsafeConstructorSuggests replacing Effect.runSync of a pure effect constructor with the synchronous *Unsafe variant exported by the same module - returnEffectInGenWarns when returning an Effect in a generator causes nested Effect<Effect<...>> - runEffectInsideEffectSuggests using Runtime or Effect.run*With methods instead of Effect.run* inside Effect contexts - schemaSyncInEffectSuggests using Effect-based Schema methods instead of sync methods inside Effect generators - scopeInLayerEffectSuggests using Layer.scoped instead of Layer.effect when Scope is in requirements - strictEffectProvideWarns when using Effect.provide with layers outside of application entry points - tryCatchInEffectGenDiscourages try/catch in Effect generators in favor of Effect error handling - unknownInEffectCatchWarns when catch callbacks return unknown instead of typed errors + catchUnfailableEffectWarns when using error handling on Effects that never fail + effectFnIifeEffect.fn or Effect.fnUntraced is called as an IIFE; use Effect.gen instead + effectGenUsesAdapterWarns when using the deprecated adapter parameter in Effect.gen + effectInFailureWarns when an Effect is used inside an Effect failure channel + effectInVoidSuccessDetects nested Effects in void success channels that may cause unexecuted effects + globalErrorInEffectCatchWarns when catch callbacks return global Error type instead of typed errors + globalErrorInEffectFailureWarns when the global Error type is used in an Effect failure channel + layerMergeAllWithDependenciesDetects interdependencies in Layer.mergeAll calls where one layer provides a service that another layer requires + lazyEffectSuggests avoiding exported zero-argument functions and service members that lazily return Effect or Stream values + lazyPromiseInEffectSyncWarns when Effect.sync lazily returns a Promise instead of using an async Effect constructor + leakingRequirementsDetects implementation services leaked in service methods + multipleEffectProvideWarns against chaining Effect.provide calls which can cause service lifecycle issues + preferUnsafeConstructorSuggests replacing Effect.runSync of a pure effect constructor with the synchronous *Unsafe variant exported by the same module + returnEffectInGenWarns when returning an Effect in a generator causes nested Effect<Effect<...>> + runEffectInsideEffectSuggests using Runtime or Effect.run*With methods instead of Effect.run* inside Effect contexts + schemaSyncInEffectSuggests using Effect-based Schema methods instead of sync methods inside Effect generators + scopeInLayerEffectSuggests using Layer.scoped instead of Layer.effect when Scope is in requirements + strictEffectProvideWarns when using Effect.provide with layers outside of application entry points + tryCatchInEffectGenDiscourages try/catch in Effect generators in favor of Effect error handling + unknownInEffectCatchWarns when catch callbacks return unknown instead of typed errors Effect-native Prefer Effect-native APIs and abstractions when available. - abortControllerInEffectWarns when manually constructing AbortController inside Effect generators instead of using Effect.abortSignal - asyncFunctionWarns when declaring async functions and suggests using Effect values and Effect.gen for async control flow - cryptoRandomUUIDWarns when using crypto.randomUUID() outside Effect generators instead of the Effect Random module, which uses Effect-injected randomness rather than the crypto module behind the scenes - cryptoRandomUUIDInEffectWarns when using crypto.randomUUID() inside Effect generators instead of the Effect Random module, which uses Effect-injected randomness rather than the crypto module behind the scenes - extendsNativeErrorWarns when a class directly extends the native Error class - globalConsoleWarns when using console methods outside Effect generators instead of Effect.log/Logger - globalConsoleInEffectWarns when using console methods inside Effect generators instead of Effect.log/Logger - globalDateWarns when using Date.now() or new Date() outside Effect generators instead of Clock/DateTime - globalDateInEffectWarns when using Date.now() or new Date() inside Effect generators instead of Clock/DateTime - globalFetchWarns when using the global fetch function outside Effect generators instead of the Effect HTTP client - globalFetchInEffectWarns when using the global fetch function inside Effect generators instead of the Effect HTTP client - globalRandomWarns when using Math.random() outside Effect generators instead of the Random service - globalRandomInEffectWarns when using Math.random() inside Effect generators instead of the Random service - globalTimersWarns when using setTimeout/setInterval outside Effect generators instead of Effect.sleep/Schedule - globalTimersInEffectWarns when using setTimeout/setInterval inside Effect generators instead of Effect.sleep/Schedule - instanceOfSchemaSuggests using Schema.is instead of instanceof for Effect Schema types - newPromiseWarns when constructing promises with new Promise instead of using Effect APIs - nodeBuiltinImportWarns when importing Node.js built-in modules that have Effect-native counterparts - preferSchemaOverJsonSuggests using Effect Schema for JSON operations instead of JSON.parse/JSON.stringify - processEnvWarns when reading process.env outside Effect generators instead of using Effect Config - processEnvInEffectWarns when reading process.env inside Effect generators instead of using Effect Config - unsafeEffectTypeAssertionDetects unsafe type assertions that narrow Effect, Stream, or Layer error or requirements channels + abortControllerInEffectWarns when manually constructing AbortController inside Effect generators instead of using Effect.abortSignal + asyncFunctionWarns when declaring async functions and suggests using Effect values and Effect.gen for async control flow + cryptoRandomUUIDWarns when using crypto.randomUUID() outside Effect generators instead of the Effect Random module, which uses Effect-injected randomness rather than the crypto module behind the scenes + cryptoRandomUUIDInEffectWarns when using crypto.randomUUID() inside Effect generators instead of the Effect Random module, which uses Effect-injected randomness rather than the crypto module behind the scenes + extendsNativeErrorWarns when a class directly extends the native Error class + globalConsoleWarns when using console methods outside Effect generators instead of Effect.log/Logger + globalConsoleInEffectWarns when using console methods inside Effect generators instead of Effect.log/Logger + globalDateWarns when using Date.now() or new Date() outside Effect generators instead of Clock/DateTime + globalDateInEffectWarns when using Date.now() or new Date() inside Effect generators instead of Clock/DateTime + globalFetchWarns when using the global fetch function outside Effect generators instead of the Effect HTTP client + globalFetchInEffectWarns when using the global fetch function inside Effect generators instead of the Effect HTTP client + globalRandomWarns when using Math.random() outside Effect generators instead of the Random service + globalRandomInEffectWarns when using Math.random() inside Effect generators instead of the Random service + globalTimersWarns when using setTimeout/setInterval outside Effect generators instead of Effect.sleep/Schedule + globalTimersInEffectWarns when using setTimeout/setInterval inside Effect generators instead of Effect.sleep/Schedule + instanceOfSchemaSuggests using Schema.is instead of instanceof for Effect Schema types + newPromiseWarns when constructing promises with new Promise instead of using Effect APIs + nodeBuiltinImportWarns when importing Node.js built-in modules that have Effect-native counterparts + preferSchemaOverJsonSuggests using Effect Schema for JSON operations instead of JSON.parse/JSON.stringify + processEnvWarns when reading process.env outside Effect generators instead of using Effect Config + processEnvInEffectWarns when reading process.env inside Effect generators instead of using Effect Config + unsafeEffectTypeAssertionDetects unsafe type assertions that narrow Effect, Stream, or Layer error or requirements channels Style Cleanup, consistency, and idiomatic Effect code. - catchAllToMapErrorSuggests using Effect.mapError instead of Effect.catch + Effect.fail - catchChainToFirstSuccessOfSuggests Effect.firstSuccessOf for consecutive error-independent Effect.catch fallbacks when the error type is preserved - catchTagToCatchReasonSuggests Effect.catchReason or Effect.catchReasons for handlers that re-fail unmatched reason._tag branches - catchToIgnoreSuggests using Effect.ignore or Effect.ignoreCause instead of Effect.catch/catchCause returning Effect.void - catchToOrElseSucceedSuggests using Effect.orElseSucceed instead of Effect.catch + Effect.succeed - deterministicKeysEnforces deterministic naming for service/tag/error identifiers based on class names - effectDoNotationSuggests using Effect.gen or Effect.fn instead of the Effect.Do notation helpers - effectFnOpportunitySuggests using Effect.fn for functions that return an Effect - effectMapFlattenSuggests using Effect.flatMap instead of Effect.map followed by Effect.flatten in piping flows - effectMapVoidSuggests using Effect.asVoid instead of Effect.map(() => void 0), Effect.map(() => undefined), or Effect.map(() => {}) - effectSucceedWithVoidSuggests using Effect.void instead of Effect.succeed(undefined) or Effect.succeed(void 0) - flatMapToMapSuggests using Effect.map instead of Effect.flatMap when the callback only wraps its result with Effect.succeed - missedPipeableOpportunitySuggests using .pipe() for nested function calls - missingEffectServiceDependencyChecks that Effect.Service dependencies satisfy all required layer inputs - missingPipeableSignatureReports exported fixed-arity functions whose call signatures have no corresponding pipeable overload - multipleCatchTagSuggests collapsing consecutive Effect.catchTag transformations into a single Effect.catchTags call when semantics stay equivalent - nestedEffectGenYieldWarns when yielding a nested bare Effect.gen inside an existing Effect generator context - newSchemaClassSuggests using Schema make instead of new for Schema classes - preferSchemaTypePropertyDisallows Schema.Schema.Type<typeof X> in favor of typeof X.Type - preferTypedSchemaDecoderSuggests typed Schema decoders when the input is assignable to the schema's Encoded type - redundantMapErrorSuggests hoisting a repeated trailing Effect.mapError from every yield in an Effect generator - redundantOrDieSuggests hoisting a repeated trailing Effect.orDie from every yield in an Effect generator - redundantSchemaTagIdentifierSuggests removing redundant identifier argument when it equals the tag value in Schema.TaggedClass/TaggedError/TaggedRequest - schemaNumberSuggests Schema.Finite and Schema.FiniteFromString instead of Schema.Number APIs when describing domain numbers - schemaStructWithTagSuggests using Schema.TaggedStruct instead of Schema.Struct with _tag field - schemaUnionOfLiteralsSuggests combining multiple Schema.Literal calls in Schema.Union into a single Schema.Literal - serviceNotAsClassWarns when Context.Service is used as a variable instead of a class declaration - strictBooleanExpressionsEnforces boolean types in conditional expressions for type safety - syncToSucceedSuggests using Effect.succeed instead of Effect.sync when the thunk returns a constant value - unnecessaryArrowBlockSuggests using a concise arrow body when the block only returns an expression - unnecessaryEffectGenSuggests removing Effect.gen when it contains only a single return statement - unnecessaryFailYieldableErrorSuggests yielding yieldable errors directly instead of wrapping with Effect.fail - unnecessaryPipeRemoves pipe calls with no arguments - unnecessaryPipeChainSimplifies chained pipe calls into a single pipe call - unnecessaryTypeofTypeSuggests replacing typeof Schema.Type style annotations with the matching named type when available + catchAllToMapErrorSuggests using Effect.mapError instead of Effect.catch + Effect.fail + catchChainToFirstSuccessOfSuggests Effect.firstSuccessOf for consecutive error-independent Effect.catch fallbacks when the error type is preserved + catchTagToCatchReasonSuggests Effect.catchReason or Effect.catchReasons for handlers that re-fail unmatched reason._tag branches + catchToIgnoreSuggests using Effect.ignore or Effect.ignoreCause instead of Effect.catch/catchCause returning Effect.void + catchToOrElseSucceedSuggests using Effect.orElseSucceed instead of Effect.catch + Effect.succeed + deterministicKeysEnforces deterministic naming for service/tag/error identifiers based on class names + effectDoNotationSuggests using Effect.gen or Effect.fn instead of the Effect.Do notation helpers + effectFnOpportunitySuggests using Effect.fn for functions that return an Effect + effectMapFlattenSuggests using Effect.flatMap instead of Effect.map followed by Effect.flatten in piping flows + effectMapVoidSuggests using Effect.asVoid instead of Effect.map(() => void 0), Effect.map(() => undefined), or Effect.map(() => {}) + effectSucceedWithVoidSuggests using Effect.void instead of Effect.succeed(undefined) or Effect.succeed(void 0) + flatMapToMapSuggests using Effect.map instead of Effect.flatMap when the callback only wraps its result with Effect.succeed + missedPipeableOpportunitySuggests using .pipe() for nested function calls + missingEffectServiceDependencyChecks that Effect.Service dependencies satisfy all required layer inputs + missingPipeableSignatureReports exported fixed-arity functions whose call signatures have no corresponding pipeable overload + multipleCatchTagSuggests collapsing consecutive Effect.catchTag transformations into a single Effect.catchTags call when semantics stay equivalent + nestedEffectGenYieldWarns when yielding a nested bare Effect.gen inside an existing Effect generator context + newSchemaClassSuggests using Schema make instead of new for Schema classes + preferSchemaTypePropertyDisallows Schema.Schema.Type<typeof X> in favor of typeof X.Type + preferTypedSchemaDecoderSuggests typed Schema decoders when the input is assignable to the schema's Encoded type + redundantMapErrorSuggests hoisting a repeated trailing Effect.mapError from every yield in an Effect generator + redundantOrDieSuggests hoisting a repeated trailing Effect.orDie from every yield in an Effect generator + redundantSchemaTagIdentifierSuggests removing redundant identifier argument when it equals the tag value in Schema.TaggedClass/TaggedError/TaggedRequest + schemaNumberSuggests Schema.Finite and Schema.FiniteFromString instead of Schema.Number APIs when describing domain numbers + schemaStructWithTagSuggests using Schema.TaggedStruct instead of Schema.Struct with _tag field + schemaUnionOfLiteralsSuggests combining multiple Schema.Literal calls in Schema.Union into a single Schema.Literal + serviceNotAsClassWarns when Context.Service is used as a variable instead of a class declaration + strictBooleanExpressionsEnforces boolean types in conditional expressions for type safety + syncToSucceedSuggests using Effect.succeed instead of Effect.sync when the thunk returns a constant value + unnecessaryArrowBlockSuggests using a concise arrow body when the block only returns an expression + unnecessaryEffectGenSuggests removing Effect.gen when it contains only a single return statement + unnecessaryFailYieldableErrorSuggests yielding yieldable errors directly instead of wrapping with Effect.fail + unnecessaryPipeRemoves pipe calls with no arguments + unnecessaryPipeChainSimplifies chained pipe calls into a single pipe call + unnecessaryTypeofTypeSuggests replacing typeof Schema.Type style annotations with the matching named type when available diff --git a/_packages/tsgo-darwin-arm64/CHANGELOG.md b/_packages/tsgo-darwin-arm64/CHANGELOG.md index 57050fb6..d043b398 100644 --- a/_packages/tsgo-darwin-arm64/CHANGELOG.md +++ b/_packages/tsgo-darwin-arm64/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-darwin-arm64 +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-darwin-arm64/package.json b/_packages/tsgo-darwin-arm64/package.json index e265f9d6..2394146b 100644 --- a/_packages/tsgo-darwin-arm64/package.json +++ b/_packages/tsgo-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-darwin-arm64", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo-darwin-x64/CHANGELOG.md b/_packages/tsgo-darwin-x64/CHANGELOG.md index 7cf90a27..760cca23 100644 --- a/_packages/tsgo-darwin-x64/CHANGELOG.md +++ b/_packages/tsgo-darwin-x64/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-darwin-x64 +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-darwin-x64/package.json b/_packages/tsgo-darwin-x64/package.json index 62662e98..a5b6c9e1 100644 --- a/_packages/tsgo-darwin-x64/package.json +++ b/_packages/tsgo-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-darwin-x64", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo-linux-arm/CHANGELOG.md b/_packages/tsgo-linux-arm/CHANGELOG.md index bea7cf56..7f21b730 100644 --- a/_packages/tsgo-linux-arm/CHANGELOG.md +++ b/_packages/tsgo-linux-arm/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-linux-arm +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-linux-arm/package.json b/_packages/tsgo-linux-arm/package.json index c6cb4e72..977f9886 100644 --- a/_packages/tsgo-linux-arm/package.json +++ b/_packages/tsgo-linux-arm/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-linux-arm", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo-linux-arm64/CHANGELOG.md b/_packages/tsgo-linux-arm64/CHANGELOG.md index f3f659fe..ad7b51ff 100644 --- a/_packages/tsgo-linux-arm64/CHANGELOG.md +++ b/_packages/tsgo-linux-arm64/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-linux-arm64 +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-linux-arm64/package.json b/_packages/tsgo-linux-arm64/package.json index d018c2a8..38ddca9c 100644 --- a/_packages/tsgo-linux-arm64/package.json +++ b/_packages/tsgo-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-linux-arm64", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo-linux-x64/CHANGELOG.md b/_packages/tsgo-linux-x64/CHANGELOG.md index 4c2d061c..276d50d0 100644 --- a/_packages/tsgo-linux-x64/CHANGELOG.md +++ b/_packages/tsgo-linux-x64/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-linux-x64 +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-linux-x64/package.json b/_packages/tsgo-linux-x64/package.json index 9581b7c4..478b0eb7 100644 --- a/_packages/tsgo-linux-x64/package.json +++ b/_packages/tsgo-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-linux-x64", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo-win32-arm64/CHANGELOG.md b/_packages/tsgo-win32-arm64/CHANGELOG.md index 14860086..9db3546e 100644 --- a/_packages/tsgo-win32-arm64/CHANGELOG.md +++ b/_packages/tsgo-win32-arm64/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-win32-arm64 +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-win32-arm64/package.json b/_packages/tsgo-win32-arm64/package.json index 27767b2d..3f112fe1 100644 --- a/_packages/tsgo-win32-arm64/package.json +++ b/_packages/tsgo-win32-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-win32-arm64", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo-win32-x64/CHANGELOG.md b/_packages/tsgo-win32-x64/CHANGELOG.md index 0275fde8..90d54829 100644 --- a/_packages/tsgo-win32-x64/CHANGELOG.md +++ b/_packages/tsgo-win32-x64/CHANGELOG.md @@ -1,5 +1,19 @@ # @effect/tsgo-win32-x64 +## 0.36.4 + +## 0.36.3 + +## 0.36.2 + +## 0.36.1 + +## 0.36.0 + +## 0.35.0 + +## 0.34.0 + ## 0.33.0 ## 0.32.1 diff --git a/_packages/tsgo-win32-x64/package.json b/_packages/tsgo-win32-x64/package.json index f80a20d3..83f2ebaf 100644 --- a/_packages/tsgo-win32-x64/package.json +++ b/_packages/tsgo-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo-win32-x64", - "version": "0.33.0", + "version": "0.36.4", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", "repository": { diff --git a/_packages/tsgo/.gitignore b/_packages/tsgo/.gitignore index f2276e5f..5baa34a7 100644 --- a/_packages/tsgo/.gitignore +++ b/_packages/tsgo/.gitignore @@ -2,4 +2,5 @@ dist/ node_modules/ README.md oxlint-schema.json +oxlint-presets/ schema.json diff --git a/_packages/tsgo/CHANGELOG.md b/_packages/tsgo/CHANGELOG.md index d2a37649..71487b0b 100644 --- a/_packages/tsgo/CHANGELOG.md +++ b/_packages/tsgo/CHANGELOG.md @@ -1,5 +1,70 @@ # @effect/tsgo +## 0.36.4 + +### Patch Changes + +- 7a616ec: Allow declaration emit with `noEmitOnError` when all diagnostics are ignored Effect diagnostics. + +## 0.36.3 + +### Patch Changes + +- 73f7f28: Refresh patched binaries when an updated `@effect/tsgo` package provides a different replacement artifact. +- 9190801: Update Effect v4 dependencies and embedded test fixtures to `4.0.0-beta.107`. + +## 0.36.2 + +### Patch Changes + +- 8362acc: Extend the walker-rule prefilter to call expressions. + + A call expression's type is its resolved signature's return type, and both the signature and that return type are already cached from the main check phase. `NodeCouldBeStrictEffect` now consults them for call nodes and skips the expensive flow-analysis re-check when the declared return type conclusively cannot be a strict Effect. Signature-less calls, optional chains, and every inconclusive return type stay conservative, and `promiseInEffectSuccess` no longer computes a location type for calls only to discard it. Emitted diagnostics are unchanged; on a large Effect monorepo build this removes a further ~4.6% of wall time on top of the reference-node prefilter, bringing the total Effect diagnostics overhead versus a pristine tsgo build of the same commit down to ~17%. +- 9020153: Skip diagnostic rules below the minimum visible severity before executing them. + + In `tsc` CLI mode without `includeSuggestionsInTsc`, suggestion- and message-severity diagnostics are dropped from the output after rules run. The rule runner now receives the minimum severity the caller can surface and skips such rules up front, avoiding their type-checker queries entirely. A rule below the threshold still runs when any directive in the file references it (for example `// @effect-diagnostics ruleName:error` or a wildcard), since directives can raise its severity and must be tracked for `unusedDirective` reporting. Emitted diagnostics are unchanged; on a large Effect monorepo build this removes roughly 1–2s of check time. +- 257af25: Skip flow-analysis type queries for references that conclusively cannot be an Effect. + + The `effectInFailure` and `promiseInEffectSuccess` rules walk every node of a file and query its flow type just to test whether it is a strict Effect type. The new `TypeParser.NodeCouldBeStrictEffect` prefilter inspects the referenced symbol's declared type first — flow narrowing can only refine the declared type, so a declared type that conclusively contains no possibly-Effect constituent (primitives, plain objects with a different type name, unions thereof) can never produce a strict Effect flow type, and the expensive query is skipped. The predicate is conservative: `any`/`unknown`, type parameters, conditionals, symbol-less types, and deep unions always fall through to the full query. Emitted diagnostics are unchanged; on a large Effect monorepo build this removes ~10% of build wall time (~2.7s of ~26.8s). + +## 0.36.1 + +### Patch Changes + +- 4db1d4b: Preserve existing indentation and newline styles when setup updates JSON configuration files. +- c154a04: Update the TypeScript next tag to [`typescript@next`](https://www.npmjs.com/package/typescript/v/7.1.0-dev.20260808.1), which ships [`typescript-go`](https://github.com/microsoft/typescript-go/commit/24fabe95acba758c05fcb349bf427a3a0c8ad676) commit `24fabe95acba758c05fcb349bf427a3a0c8ad676`, and update the TypeScript latest tag to [`typescript@latest`](https://www.npmjs.com/package/typescript/v/7.0.2). + +## 0.36.0 + +### Minor Changes + +- 8423f68: Add recommended and category-specific shared configurations for Oxlint. + +## 0.35.0 + +### Minor Changes + +- f25821b: Generate detailed upstream update pull request descriptions with explicit version changes and the TypeScript-Go commits introduced by the update. + +### Patch Changes + +- 3fe06c8: Avoid false positive TS2731 diagnostics for symbol-valued interpolations in tagged template literals when Effect diagnostic rules traverse the template expression. +- 989964b: Prevent `preferTypedSchemaDecoder` from panicking when a decoder input is produced by a preceding call or pipe transformation. +- 86c30b1: Persist Effect plugin options in TypeScript build information so incremental builds recheck semantic diagnostics when extended tsconfig plugin settings change. +- b8e7314: Update the TypeScript next tag to [`typescript@next`](https://www.npmjs.com/package/typescript/v/7.1.0-dev.20260806.1), which ships [`typescript-go`](https://github.com/microsoft/typescript-go/commit/86cc4767d4ebadb9b7845d0ab8eb2b05785c3fee) commit `86cc4767d4ebadb9b7845d0ab8eb2b05785c3fee`, and update the TypeScript latest tag to [`typescript@latest`](https://www.npmjs.com/package/typescript/v/7.0.2). + +## 0.34.0 + +### Minor Changes + +- 974513e: Add a non-interactive `setup` workflow with explicit project, integration, diagnostic, editor, preview, and apply options. Setup now also recommends running the package manager install command whenever it changes `package.json`. + +### Patch Changes + +- ba4ec9a: Cache generated TypeScript-Go shims by their effective inputs during local repository setup and CI runs. +- 6d0ffda: Remove unused internal helpers and add dead-code analysis to the repository lint workflow. +- 892021a: Use absolute GitHub URLs for links in the published README so documentation links remain clickable on npm. + ## 0.33.0 ### Minor Changes diff --git a/_packages/tsgo/package.json b/_packages/tsgo/package.json index bff941b3..74ed4bec 100644 --- a/_packages/tsgo/package.json +++ b/_packages/tsgo/package.json @@ -1,6 +1,6 @@ { "name": "@effect/tsgo", - "version": "0.33.0", + "version": "0.36.4", "type": "module", "description": "Effect Language Service for TypeScript-Go — Effect-specific diagnostics and hover features.", "license": "MIT", @@ -31,12 +31,20 @@ "./lib/getExePath": { "types": "./lib/getExePath.d.ts", "default": "./lib/getExePath.js" + }, + "./oxlint-presets": { + "types": "./oxlint-presets/index.d.ts", + "default": "./oxlint-presets/index.js" + }, + "./oxlint-presets/*.json": { + "default": "./oxlint-presets/*.json" } }, "files": [ "dist/", "lib/", "README.md", + "oxlint-presets/", "oxlint-schema.json", "schema.json" ], @@ -50,12 +58,12 @@ "@effect/tsgo-darwin-arm64": "workspace:*" }, "devDependencies": { - "@effect/platform-node": "^4.0.0-beta.104", - "@effect/platform-node-shared": "^4.0.0-beta.104", + "@effect/platform-node": "^4.0.0-beta.107", + "@effect/platform-node-shared": "^4.0.0-beta.107", "@types/node": "^24.3.0", "tsdown": "^0.20.1", "typescript": "^5.9.2", - "effect": "^4.0.0-beta.104", + "effect": "^4.0.0-beta.107", "vitest": "^3.2.1" } } diff --git a/_packages/tsgo/src/cli/setup/changes.ts b/_packages/tsgo/src/cli/setup/changes.ts index 1f4c9c41..e1ce5f73 100644 --- a/_packages/tsgo/src/cli/setup/changes.ts +++ b/_packages/tsgo/src/cli/setup/changes.ts @@ -90,23 +90,70 @@ function deleteNodeFromList( } } -/** - * Insert a node at the end of a list (array or object properties), handling commas properly - */ +const emptyListInsertions = new WeakMap>() + +/** + * Insert a node at the end of a list (array or object properties), handling commas properly + */ function insertNodeAtEndOfList( tracker: any, sourceFile: ts.SourceFile, nodeArray: ts.NodeArray, - newNode: T -) { - if (nodeArray.length === 0) { - tracker.insertNodeAt(sourceFile, nodeArray.pos + 1, newNode, { suffix: "\n" }) - } else { - const lastElement = nodeArray[nodeArray.length - 1] - tracker.insertNodeAt(sourceFile, lastElement.end, newNode, { prefix: ",\n" }) + newNode: T +) { + const formatOptions = getFormatOptions(sourceFile) + if (nodeArray.length === 0) { + const insertedLists = emptyListInsertions.get(tracker) ?? new WeakSet() + const hasPreviousInsertion = insertedLists.has(nodeArray) + insertedLists.add(nodeArray) + emptyListInsertions.set(tracker, insertedLists) + const closingIndentation = getIndentationAtPosition(sourceFile, nodeArray.pos, formatOptions.tabSize) + const hasFollowingLineBreak = /^[ \t]*\r?\n/.test(sourceFile.text.slice(nodeArray.pos)) + tracker.insertNodeAt(sourceFile, nodeArray.pos, newNode, { + indentation: closingIndentation.column + formatOptions.indentSize, + prefix: formatOptions.newLineCharacter, + suffix: hasPreviousInsertion + ? "," + : hasFollowingLineBreak ? "" : `${formatOptions.newLineCharacter}${closingIndentation.text}` + }) + } else { + const lastElement = nodeArray[nodeArray.length - 1] + const indentation = getIndentationAtPosition( + sourceFile, + lastElement.getStart(sourceFile), + formatOptions.tabSize + ) + tracker.insertNodeAt(sourceFile, lastElement.end, newNode, { + indentation: indentation.column, + prefix: `,${formatOptions.newLineCharacter}` + }) } } +function getFormatOptions(sourceFile: ts.SourceFile) { + const indentation = sourceFile.text.match(/^([ \t]+)\S/m)?.[1] + const indentSize = indentation?.includes("\t") ? 2 : indentation?.length ?? 2 + const newLineCharacter = sourceFile.text.includes("\r\n") ? "\r\n" : "\n" + return { + ...ts.getDefaultFormatCodeSettings(newLineCharacter), + indentSize, + tabSize: indentSize, + convertTabsToSpaces: !indentation?.includes("\t"), + newLineCharacter + } +} + +function getIndentationAtPosition(sourceFile: ts.SourceFile, position: number, tabSize: number) { + const { line } = sourceFile.getLineAndCharacterOfPosition(position) + const lineStart = sourceFile.getPositionOfLineAndCharacter(line, 0) + const text = sourceFile.text.slice(lineStart, position).match(/^[ \t]*/)?.[0] ?? "" + const column = [...text].reduce( + (column, character) => character === "\t" ? column + tabSize - (column % tabSize) : column + 1, + 0 + ) + return { column, text } +} + function findDependencyCollectionProperty( rootObj: ts.ObjectLiteralExpression, dependencyType: "dependencies" | "devDependencies" @@ -211,10 +258,10 @@ const tsInternal = ts as any /** * Create a ChangeTracker context */ -function createTrackerContext() { - const host = createMinimalHost() - const formatOptions = { indentSize: 2, tabSize: 2 } as ts.EditorSettings - const formatContext = tsInternal.formatting.getFormatContext(formatOptions, host) +function createTrackerContext(sourceFile: ts.SourceFile) { + const host = createMinimalHost() + const formatOptions = getFormatOptions(sourceFile) + const formatContext = tsInternal.formatting.getFormatContext(formatOptions, host) const preferences = {} as ts.UserPreferences return { host, formatContext, preferences } } @@ -234,7 +281,7 @@ const computePackageJsonChanges = ( return emptyFileChangesResult() } - const ctx = createTrackerContext() + const ctx = createTrackerContext(current.sourceFile) const fileChanges = tsInternal.textChanges.ChangeTracker.with( ctx, @@ -606,7 +653,7 @@ const computeTsConfigChanges = ( const schemaProperty = findPropertyInObject(rootObj, "$schema") if (!isEffectSchemaProperty(schemaProperty)) return emptyFileChangesResult() - const ctx = createTrackerContext() + const ctx = createTrackerContext(current.sourceFile) const fileChanges = tsInternal.textChanges.ChangeTracker.with(ctx, (tracker: any) => { descriptions.push("Remove $schema from tsconfig") deleteNodeFromList(tracker, current.sourceFile, rootObj.properties, schemaProperty!) @@ -628,7 +675,7 @@ const computeTsConfigChanges = ( } // Create compilerOptions with the plugin entry - const ctx = createTrackerContext() + const ctx = createTrackerContext(current.sourceFile) const fileChanges = tsInternal.textChanges.ChangeTracker.with( ctx, @@ -706,7 +753,7 @@ const computeTsConfigChanges = ( const compilerOptions = compilerOptionsProperty.initializer - const ctx = createTrackerContext() + const ctx = createTrackerContext(current.sourceFile) const fileChanges = tsInternal.textChanges.ChangeTracker.with( ctx, @@ -858,7 +905,7 @@ const computeOxlintConfigChanges = ( ts.factory.createStringLiteral("$schema"), ts.factory.createStringLiteral(schemaPath.value) ) - const ctx = createTrackerContext() + const ctx = createTrackerContext(current.sourceFile) const fileChanges = tsInternal.textChanges.ChangeTracker.with(ctx, (tracker: any) => { if (schemaProperty) { tracker.replaceNode(current.sourceFile, schemaProperty.initializer, schemaPropertyAssignment.initializer) @@ -897,7 +944,7 @@ const computeVSCodeSettingsChanges = ( return emptyFileChangesResult() } - const ctx = createTrackerContext() + const ctx = createTrackerContext(current.sourceFile) const createSettingValue = (value: unknown): ts.Expression => typeof value === "string" diff --git a/_packages/tsgo/src/patcher/discovery.ts b/_packages/tsgo/src/patcher/discovery.ts index 5ca2db98..f7239b18 100644 --- a/_packages/tsgo/src/patcher/discovery.ts +++ b/_packages/tsgo/src/patcher/discovery.ts @@ -3,6 +3,7 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import * as Path from "effect/Path" +import { hashFile } from "./fileHash.js" import type { Component, DiscoveredBinary } from "./types.js" export const defaultTypescriptPackageNames = ["typescript", "@typescript/native"] as const @@ -19,6 +20,8 @@ interface PackageMetadata { readonly main?: string } +type DiscoveredBinaryLocation = Omit + const isNativeTypescriptVersion = (version: string) => { const match = /\d+/.exec(version.trim()) return match !== null && Number(match[0]) >= 7 @@ -82,7 +85,7 @@ export const experimentalOxlintTarget = (platform: NodeJS.Platform, arch: string const discoverTypeScript: ( cwdRequire: NodeJS.Require, preferredPackage?: string -) => Effect.Effect = ( +) => Effect.Effect = ( cwdRequire, preferredPackage ) => Effect.gen(function*() { @@ -102,14 +105,14 @@ const discoverTypeScript: ( packageName: platformPackageName, packageVersion: platformPackage.version, binaryPath: path.join(path.dirname(platformPackage.packageJsonPath), "lib", binaryName) - } satisfies DiscoveredBinary] + } satisfies DiscoveredBinaryLocation] } return [] }) const discoverOxlint: ( cwdRequire: NodeJS.Require -) => Effect.Effect = (cwdRequire) => +) => Effect.Effect = (cwdRequire) => Effect.gen(function*() { const path = yield* Path.Path const oxlint = yield* optionally(readPackage(cwdRequire, "oxlint")) @@ -121,7 +124,7 @@ const discoverOxlint: ( ? error : new DiscoveryError({ reason: "Unable to determine the Oxlint platform target." }) }) - const discovered: Array = [] + const discovered: Array = [] if (oxlint !== undefined) { const binding = yield* readPackage(nodeModule.createRequire(oxlint.packageJsonPath), target.oxlintPackage) if (binding.main === undefined) { @@ -157,7 +160,7 @@ const discoverOxlint: ( const discoverVitePlusOxlint: ( cwdRequire: NodeJS.Require -) => Effect.Effect = (cwdRequire) => +) => Effect.Effect = (cwdRequire) => Effect.gen(function*() { const vitePlus = yield* optionally(readPackage(cwdRequire, "vite-plus")) if (vitePlus === undefined) return [] @@ -170,9 +173,13 @@ export const discoverBinaries = (cwd: string, preferredTypescriptPackage?: strin const typescript = yield* discoverTypeScript(cwdRequire, preferredTypescriptPackage) const oxlint = yield* discoverOxlint(cwdRequire) const vitePlusOxlint = yield* discoverVitePlusOxlint(cwdRequire) - return [...new Map( + const discovered = [...new Map( [...typescript, ...oxlint, ...vitePlusOxlint].map((binary) => [binary.binaryPath, binary]) ).values()] + return yield* Effect.forEach(discovered, (binary) => hashFile(binary.binaryPath).pipe( + Effect.map((fileHash) => ({ ...binary, fileHash })), + Effect.mapError(() => new DiscoveryError({ reason: `Unable to read discovered binary ${binary.binaryPath}.` })) + )) }) export const selectComponents = ( diff --git a/_packages/tsgo/src/patcher/fileHash.ts b/_packages/tsgo/src/patcher/fileHash.ts new file mode 100644 index 00000000..4918df89 --- /dev/null +++ b/_packages/tsgo/src/patcher/fileHash.ts @@ -0,0 +1,17 @@ +import * as Crypto from "effect/Crypto" +import * as Effect from "effect/Effect" +import * as Encoding from "effect/Encoding" +import * as FileSystem from "effect/FileSystem" + +const textEncoder = new TextEncoder() + +export const hashBytes = (contents: string | Uint8Array) => Effect.gen(function*() { + const crypto = yield* Crypto.Crypto + const digest = yield* crypto.digest("SHA-256", typeof contents === "string" ? textEncoder.encode(contents) : contents) + return Encoding.encodeHex(digest) +}) + +export const hashFile = (filePath: string) => Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + return yield* hashBytes(yield* fs.readFile(filePath)) +}) diff --git a/_packages/tsgo/src/patcher/index.ts b/_packages/tsgo/src/patcher/index.ts index ad327671..ef97a372 100644 --- a/_packages/tsgo/src/patcher/index.ts +++ b/_packages/tsgo/src/patcher/index.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import * as nodeModule from "node:module" +import * as Crypto from "effect/Crypto" import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" @@ -7,6 +8,7 @@ import * as Path from "effect/Path" import * as Scope from "effect/Scope" import metadataJson from "../metadata.json" with { type: "json" } import { discoverBinaries, requireComponents, selectComponents } from "./discovery.js" +import { hashBytes, hashFile } from "./fileHash.js" import type { Component, DiscoveredBinary, @@ -40,6 +42,7 @@ const exists = (fs: FileSystem.FileSystem, filePath: string) => fs.exists(filePa export interface ResolvedReplacement { readonly path: string + readonly fileHash: string } export type ReplacementResolver = ( @@ -47,7 +50,7 @@ export type ReplacementResolver = ( ) => Effect.Effect< ResolvedReplacement, PatcherError | ReplacementUnavailableError, - FileSystem.FileSystem | Path.Path | Scope.Scope + Crypto.Crypto | FileSystem.FileSystem | Path.Path | Scope.Scope > const toKebabCase = (value: string): string => { @@ -96,9 +99,11 @@ export const renderOxlintDeclarations = (source: string): string => { const resolveOxlintDeclarations = (target: DiscoveredBinary) => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const source = yield* fs.readFileString(target.binaryPath).pipe( + const backupPath = `${target.binaryPath}.original` + const sourcePath = (yield* exists(fs, backupPath)) ? backupPath : target.binaryPath + const source = yield* fs.readFileString(sourcePath).pipe( Effect.mapError((error) => new PatcherError({ - reason: `Unable to read Oxlint declarations at ${target.binaryPath}: ${error.message}` + reason: `Unable to read Oxlint declarations at ${sourcePath}: ${error.message}` })) ) const replacement = yield* Effect.try({ @@ -117,7 +122,12 @@ const resolveOxlintDeclarations = (target: DiscoveredBinary) => Effect.gen(funct reason: `Unable to write generated Oxlint declarations at ${replacementPath}: ${error.message}` })) ) - return { path: replacementPath } + const fileHash = yield* hashBytes(replacement).pipe( + Effect.mapError((error) => new PatcherError({ + reason: `Unable to hash generated Oxlint declarations: ${error.message}` + })) + ) + return { path: replacementPath, fileHash } }) const resolvePlatformPackage = (target: DiscoveredBinary) => Effect.gen(function*() { @@ -152,7 +162,12 @@ export const resolveReplacement: ReplacementResolver = (target) => Effect.gen(fu reason: `Missing packaged artifact ${replacementPath}.` }) } - return { path: replacementPath } + const fileHash = yield* hashFile(replacementPath).pipe( + Effect.mapError((error) => new PatcherError({ + reason: `Unable to hash packaged artifact ${replacementPath}: ${error.message}` + })) + ) + return { path: replacementPath, fileHash } }) export interface PreparePatchOptions { @@ -169,8 +184,13 @@ export const preparePatch = ( const fs = yield* FileSystem.FileSystem const resolver = options.resolveReplacement ?? resolveReplacement const operations: Array = [] + const cleanup: Array = [] const skipped: Array = [] - const available: Array<{ readonly target: DiscoveredBinary; readonly replacementPath: string }> = [] + const available: Array<{ + readonly target: DiscoveredBinary + readonly replacementPath: string + readonly backupExists: boolean + }> = [] for (const target of targets) { const backupPath = `${target.binaryPath}.original` @@ -179,14 +199,6 @@ export const preparePatch = ( if (!targetExists) { return yield* new PatcherError({ reason: `Installed binary does not exist: ${target.binaryPath}` }) } - if (backupExists) { - skipped.push({ - target, - reason: "already-patched", - message: `${target.component} skipped because backup already exists at ${backupPath}.` - }) - continue - } const replacement = yield* resolver(target).pipe( Effect.catchTag("ReplacementUnavailableError", (error) => { if (!options.skipMissing) return Effect.fail(error) @@ -195,12 +207,26 @@ export const preparePatch = ( }) ) if (replacement === undefined) continue - available.push({ target, replacementPath: replacement.path }) + if (backupExists && target.fileHash === replacement.fileHash) { + skipped.push({ + target, + reason: "already-patched", + message: `${target.component} skipped because its hash matches the replacement.` + }) + continue + } + available.push({ target, replacementPath: replacement.path, backupExists }) } - for (const { replacementPath, target } of available) { + for (const { backupExists, replacementPath, target } of available) { const backupPath = `${target.binaryPath}.original` - operations.push({ _tag: "Rename", sourcePath: target.binaryPath, destinationPath: backupPath }) + if (backupExists) { + const quarantinePath = `${target.binaryPath}.${randomUUID()}.patched` + operations.push({ _tag: "Rename", sourcePath: target.binaryPath, destinationPath: quarantinePath }) + cleanup.push({ _tag: "Remove", path: quarantinePath }) + } else { + operations.push({ _tag: "Rename", sourcePath: target.binaryPath, destinationPath: backupPath }) + } operations.push({ _tag: "Copy", sourcePath: replacementPath, @@ -211,7 +237,7 @@ export const preparePatch = ( } } - return { operations, cleanup: [], skipped } satisfies PreparedPatch + return { operations, cleanup, skipped } satisfies PreparedPatch }) export const prepareUnpatch = (targets: ReadonlyArray) => Effect.gen(function*() { diff --git a/_packages/tsgo/src/patcher/types.ts b/_packages/tsgo/src/patcher/types.ts index d98e03a4..b4d510f2 100644 --- a/_packages/tsgo/src/patcher/types.ts +++ b/_packages/tsgo/src/patcher/types.ts @@ -5,6 +5,7 @@ export interface DiscoveredBinary { readonly packageName: string readonly packageVersion: string readonly binaryPath: string + readonly fileHash: string } export type FileSystemOperation = RenameOperation | CopyOperation | ChmodOperation | RemoveOperation diff --git a/_packages/tsgo/test/experimental-oxlint.test.ts b/_packages/tsgo/test/experimental-oxlint.test.ts index 6483f45c..2d2dd95a 100644 --- a/_packages/tsgo/test/experimental-oxlint.test.ts +++ b/_packages/tsgo/test/experimental-oxlint.test.ts @@ -1,13 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" +import { createHash } from "node:crypto" import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { dirname, join } from "node:path" import { afterEach, describe, expect, it } from "vitest" import { discoverBinaries, experimentalOxlintTarget } from "../src/patcher/index.js" const temporaryDirectories: Array = [] +const hash = (value: string) => createHash("sha256").update(value).digest("hex") + const makeTemporaryDirectory = async () => { const directory = await mkdtemp(join(tmpdir(), "effect-tsgo-oxlint-")) temporaryDirectories.push(directory) @@ -21,6 +24,11 @@ const writePackage = async (directory: string, packageName: string, packageJson: return packageDirectory } +const writeBinary = async (filePath: string, contents: string) => { + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, contents) +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) }) @@ -53,6 +61,11 @@ describe("experimental Oxlint discovery", () => { main: "oxlint.node" }) const tsgolintDirectory = await writePackage(directory, platform.tsgolintPackage, { version: "2.0.1" }) + await Promise.all([ + writeBinary(join(bindingDirectory, "oxlint.node"), "oxlint"), + writeBinary(join(oxlintDirectory, "dist", "index.d.ts"), "declarations"), + writeBinary(join(tsgolintDirectory, platform.tsgolintExecutable), "tsgolint") + ]) const discovered = await Effect.runPromise( discoverBinaries(directory).pipe(Effect.provide(NodeServices.layer)) @@ -62,19 +75,22 @@ describe("experimental Oxlint discovery", () => { component: "oxlint", packageName: platform.oxlintPackage, packageVersion: "1.0.1", - binaryPath: join(bindingDirectory, "oxlint.node") + binaryPath: join(bindingDirectory, "oxlint.node"), + fileHash: hash("oxlint") }, { component: "oxlint-dts", packageName: "oxlint", packageVersion: "1.0.0", - binaryPath: join(oxlintDirectory, "dist", "index.d.ts") + binaryPath: join(oxlintDirectory, "dist", "index.d.ts"), + fileHash: hash("declarations") }, { component: "oxlint-tsgolint", packageName: platform.tsgolintPackage, packageVersion: "2.0.1", - binaryPath: join(tsgolintDirectory, platform.tsgolintExecutable) + binaryPath: join(tsgolintDirectory, platform.tsgolintExecutable), + fileHash: hash("tsgolint") } ]) }) @@ -84,6 +100,8 @@ describe("experimental Oxlint discovery", () => { await writePackage(directory, "typescript", { version: "7.0.0" }) const platformPackage = `@typescript/typescript-${process.platform}-${process.arch}` const platformDirectory = await writePackage(directory, platformPackage, { version: "7.0.1" }) + const binaryPath = join(platformDirectory, "lib", process.platform === "win32" ? "tsc.exe" : "tsc") + await writeBinary(binaryPath, "typescript") const discovered = await Effect.runPromise( discoverBinaries(directory).pipe(Effect.provide(NodeServices.layer)) @@ -92,7 +110,8 @@ describe("experimental Oxlint discovery", () => { component: "typescript", packageName: platformPackage, packageVersion: "7.0.1", - binaryPath: join(platformDirectory, "lib", process.platform === "win32" ? "tsc.exe" : "tsc") + binaryPath, + fileHash: hash("typescript") }) }) @@ -111,6 +130,11 @@ describe("experimental Oxlint discovery", () => { platform.tsgolintPackage, { version: "2.0.1" } ) + await Promise.all([ + writeBinary(join(bindingDirectory, "oxlint.node"), "oxlint"), + writeBinary(join(oxlintDirectory, "dist", "index.d.ts"), "declarations"), + writeBinary(join(tsgolintDirectory, platform.tsgolintExecutable), "tsgolint") + ]) const discovered = await Effect.runPromise( discoverBinaries(directory).pipe(Effect.provide(NodeServices.layer)) @@ -120,19 +144,22 @@ describe("experimental Oxlint discovery", () => { component: "oxlint", packageName: platform.oxlintPackage, packageVersion: "1.0.1", - binaryPath: join(bindingDirectory, "oxlint.node") + binaryPath: join(bindingDirectory, "oxlint.node"), + fileHash: hash("oxlint") }, { component: "oxlint-dts", packageName: "oxlint", packageVersion: "1.0.0", - binaryPath: join(oxlintDirectory, "dist", "index.d.ts") + binaryPath: join(oxlintDirectory, "dist", "index.d.ts"), + fileHash: hash("declarations") }, { component: "oxlint-tsgolint", packageName: platform.tsgolintPackage, packageVersion: "2.0.1", - binaryPath: join(tsgolintDirectory, platform.tsgolintExecutable) + binaryPath: join(tsgolintDirectory, platform.tsgolintExecutable), + fileHash: hash("tsgolint") } ]) }) diff --git a/_packages/tsgo/test/patcher.test.ts b/_packages/tsgo/test/patcher.test.ts index e4fc7ecc..0a94c29a 100644 --- a/_packages/tsgo/test/patcher.test.ts +++ b/_packages/tsgo/test/patcher.test.ts @@ -1,8 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Crypto from "effect/Crypto" import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import * as Path from "effect/Path" import * as Scope from "effect/Scope" +import { createHash } from "node:crypto" import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -20,13 +22,15 @@ import { const temporaryDirectories: Array = [] +const hash = (value: string) => createHash("sha256").update(value).digest("hex") + const makeTemporaryDirectory = async () => { const directory = await mkdtemp(join(tmpdir(), "effect-tsgo-patcher-")) temporaryDirectories.push(directory) return directory } -const run = (effect: Effect.Effect) => +const run = (effect: Effect.Effect) => Effect.runPromise(Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer))) afterEach(async () => { @@ -47,14 +51,27 @@ describe("patcher", () => { writeFile(secondReplacement, "second-effect") ]) const targets: ReadonlyArray = [ - { component: "oxlint-dts", packageName: "oxlint", packageVersion: "1", binaryPath: first }, - { component: "oxlint-tsgolint", packageName: "oxlint-tsgolint", packageVersion: "2", binaryPath: second } + { + component: "oxlint-dts", + packageName: "oxlint", + packageVersion: "1", + binaryPath: first, + fileHash: hash("first") + }, + { + component: "oxlint-tsgolint", + packageName: "oxlint-tsgolint", + packageVersion: "2", + binaryPath: second, + fileHash: hash("second") + } ] const plan = await run(preparePatch(targets, { skipMissing: false, resolveReplacement: (target) => Effect.succeed({ - path: target.component === "oxlint-dts" ? firstReplacement : secondReplacement + path: target.component === "oxlint-dts" ? firstReplacement : secondReplacement, + fileHash: hash(target.component === "oxlint-dts" ? "first-effect" : "second-effect") }) })) expect(plan.operations).toEqual([ @@ -69,19 +86,21 @@ describe("patcher", () => { it("generates an Oxlint declaration replacement in the temporary directory", async () => { const directory = await makeTemporaryDirectory() const declarationPath = join(directory, "index.d.ts") - await writeFile(declarationPath, [ + const declarations = [ 'type LintPluginOptionsSchema = "eslint" | "typescript";', "type RuleNoConfig = unknown;", "interface DummyRuleMap {", ' "eslint/no-unused-vars"?: RuleNoConfig;', "}", "" - ].join("\n")) + ].join("\n") + await writeFile(declarationPath, declarations) const target: DiscoveredBinary = { component: "oxlint-dts", packageName: "oxlint", packageVersion: "1.0.0", - binaryPath: declarationPath + binaryPath: declarationPath, + fileHash: hash(await readFile(declarationPath, "utf8")) } const replacement = await run(Effect.gen(function*() { @@ -96,6 +115,15 @@ describe("patcher", () => { expect(replacement.source).toContain('"effecttsgo/floating-effect"?: RuleNoConfig;') expect(replacement.source).toContain('"eslint/no-unused-vars"?: RuleNoConfig;') await expect(access(replacement.path)).rejects.toThrow() + + await writeFile(`${declarationPath}.original`, declarations) + await writeFile(declarationPath, replacement.source) + const refreshed = await run(Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const resolved = yield* resolveReplacement(target) + return yield* fs.readFileString(resolved.path) + })) + expect(refreshed).toBe(replacement.source) }) it("rejects declarations whose expected anchors changed", () => { @@ -120,7 +148,7 @@ describe("patcher", () => { await expect(access(`${target}.original`)).rejects.toThrow() }) - it("treats the persisted original backup as already patched", async () => { + it("refreshes a stale patch while preserving the original backup", async () => { const directory = await makeTemporaryDirectory() const targetPath = join(directory, "binary") const replacementPath = join(directory, "replacement") @@ -131,18 +159,45 @@ describe("patcher", () => { component: "oxlint-tsgolint", packageName: "oxlint-tsgolint", packageVersion: "1", - binaryPath: targetPath + binaryPath: targetPath, + fileHash: hash("patched") + } + const plan = await run(preparePatch([target], { + skipMissing: false, + resolveReplacement: () => Effect.succeed({ path: replacementPath, fileHash: hash("replacement") }) + })) + expect(plan.operations.map(({ _tag }) => _tag)).toEqual(["Rename", "Copy", "Chmod"]) + expect(plan.cleanup).toHaveLength(1) + expect(plan.skipped).toEqual([]) + await run(applyPlan(plan)) + expect(await readFile(targetPath, "utf8")).toBe("replacement") + expect(await readFile(`${targetPath}.original`, "utf8")).toBe("original") + expect((await readdir(directory)).filter((name) => name.endsWith(".patched"))).toEqual([]) + }) + + it("skips a patch whose target already matches the replacement", async () => { + const directory = await makeTemporaryDirectory() + const targetPath = join(directory, "binary") + const replacementPath = join(directory, "replacement") + await writeFile(targetPath, "replacement") + await writeFile(`${targetPath}.original`, "original") + await writeFile(replacementPath, "replacement") + const target: DiscoveredBinary = { + component: "oxlint-tsgolint", + packageName: "oxlint-tsgolint", + packageVersion: "1", + binaryPath: targetPath, + fileHash: hash("replacement") } const plan = await run(preparePatch([target], { skipMissing: false, - resolveReplacement: () => Effect.succeed({ path: replacementPath }) + resolveReplacement: () => Effect.succeed({ path: replacementPath, fileHash: hash("replacement") }) })) expect(plan.operations).toEqual([]) expect(plan.skipped[0]?.reason).toBe("already-patched") expect(plan.skipped[0]?.message).toBe( - `oxlint-tsgolint skipped because backup already exists at ${targetPath}.original.` + `oxlint-tsgolint skipped because its hash matches the replacement.` ) - await expect(access(`${targetPath}.original.1`)).rejects.toThrow() }) it("rolls back prior reversible operations when mutation fails", async () => { @@ -170,7 +225,8 @@ describe("patcher", () => { component: "typescript", packageName: "typescript", packageVersion: "7.0.0", - binaryPath: targetPath + binaryPath: targetPath, + fileHash: hash("patched") } await writeFile(targetPath, "patched") await writeFile(`${targetPath}.original`, "original") @@ -190,7 +246,8 @@ describe("patcher", () => { component: "oxlint", packageName: "oxlint", packageVersion: "missing", - binaryPath: targetPath + binaryPath: targetPath, + fileHash: hash("original") } const unavailable = () => Effect.fail(new ReplacementUnavailableError({ target, reason: "not packaged" })) const plan = await run(preparePatch([target], { diff --git a/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap b/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap index 1657bf11..dc306806 100644 --- a/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap +++ b/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap @@ -25,13 +25,13 @@ exports[`Setup CLI > should add LSP plugin alongside existing plugins > package. "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should add LSP plugin alongside existing plugins > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", @@ -39,9 +39,9 @@ exports[`Setup CLI > should add LSP plugin alongside existing plugins > tsconfig { "name": "typescript-plugin-css-modules" }, -{ - "name": "@effect/language-service" -} + { + "name": "@effect/language-service" + } ] } }" @@ -94,21 +94,21 @@ exports[`Setup CLI > should add LSP with VS Code editor selected and create new "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should add LSP with VS Code editor selected and create new settings file > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service" - } -] + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }" `; @@ -138,25 +138,25 @@ exports[`Setup CLI > should add LSP with custom diagnostic severities when no pl "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should add LSP with custom diagnostic severities when no plugin exists > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service", - "diagnosticSeverity": { - "floatingEffect": "warning", - "missingEffectError": "off" - } - } -] + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "floatingEffect": "warning", + "missingEffectError": "off" + } + } + ] } }" `; @@ -188,24 +188,24 @@ exports[`Setup CLI > should add custom diagnostic severities when plugin exists "dependencies": {}, "devDependencies": { "@effect/tsgo": "^0.0.5", -"typescript": "7.1.0-dev.test" + "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should add custom diagnostic severities when plugin exists without custom values > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", "plugins": [ { "name": "@effect/language-service", -"diagnosticSeverity": { - "floatingEffect": "warning", - "missingEffectContext": "message" -} + "diagnosticSeverity": { + "floatingEffect": "warning", + "missingEffectContext": "message" + } } ] } @@ -242,14 +242,14 @@ exports[`Setup CLI > should generate changes for Astro-style configs using the l "devDependencies": { "@effect/language-service": "^0.80.0", "typescript": "7.1.0-dev.test", -"@effect/tsgo": "^0.0.5" + "@effect/tsgo": "^0.0.5" } }" `; exports[`Setup CLI > should generate changes for Astro-style configs using the legacy prepare command > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "extends": "astro/tsconfigs/strictest", "include": [ ".astro/types.d.ts", @@ -305,21 +305,21 @@ exports[`Setup CLI > should generate changes for adding LSP with defaults > pack "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should generate changes for adding LSP with defaults > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service" - } -] + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }" `; @@ -349,22 +349,22 @@ exports[`Setup CLI > should generate changes for adding LSP with prepare script "name": "test-project", "version": "1.0.0", "dependencies": {}, -"scripts": { "prepare": "effect-tsgo patch --typescript --no-oxlint" }, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "scripts": { "prepare": "effect-tsgo patch --typescript --no-oxlint" }, + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should generate changes for adding LSP with prepare script > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service" - } -] + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }" `; @@ -479,13 +479,13 @@ exports[`Setup CLI > should not override existing plugins when adding LSP plugin "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should not override existing plugins when adding LSP plugin > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", @@ -499,9 +499,9 @@ exports[`Setup CLI > should not override existing plugins when adding LSP plugin { "name": "another-typescript-plugin" }, -{ - "name": "@effect/language-service" -} + { + "name": "@effect/language-service" + } ] } }" @@ -524,10 +524,10 @@ exports[`Setup CLI > should preserve all existing VS Code settings from a real r "editor.wordBasedSuggestions": "matchingDocuments", "editor.parameterHints.enabled": true, "files.insertFinalNewline": true, -"js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], -"js/ts.tsdk.promptToUseWorkspaceVersion": true, -"js/ts.tsdk.path": "./node_modules/typescript/bin", -"js/ts.experimental.useTsgo": true + "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "js/ts.tsdk.path": "./node_modules/typescript/bin", + "js/ts.experimental.useTsgo": true }" `; @@ -566,21 +566,21 @@ exports[`Setup CLI > should preserve all existing VS Code settings from a real r "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should preserve all existing VS Code settings from a real repository config > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service" - } -] + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }" `; @@ -590,10 +590,10 @@ exports[`Setup CLI > should preserve existing VS Code settings when adding LSP-s "editor.formatOnSave": true, "editor.tabSize": 2, "files.autoSave": "onFocusChange", -"js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], -"js/ts.tsdk.promptToUseWorkspaceVersion": true, -"js/ts.tsdk.path": "./node_modules/typescript/bin", -"js/ts.experimental.useTsgo": true + "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "js/ts.tsdk.path": "./node_modules/typescript/bin", + "js/ts.experimental.useTsgo": true }" `; @@ -632,21 +632,21 @@ exports[`Setup CLI > should preserve existing VS Code settings when adding LSP-s "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should preserve existing VS Code settings when adding LSP-specific settings > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service" - } -] + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }" `; @@ -766,7 +766,7 @@ exports[`Setup CLI > should replace existing tsconfig schema when adding LSP > p "name": "test-project", "version": "1.0.0", "dependencies": {}, -"devDependencies": { "@effect/tsgo": "^0.0.5","typescript": "7.1.0-dev.test" } + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } }" `; @@ -776,11 +776,11 @@ exports[`Setup CLI > should replace existing tsconfig schema when adding LSP > t "compilerOptions": { "strict": true, "target": "ES2022", -"plugins": [ - { - "name": "@effect/language-service" - } -] + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }" `; @@ -819,7 +819,7 @@ exports[`Setup CLI > should update LSP version when already installed with older exports[`Setup CLI > should update LSP version when already installed with older version > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", @@ -859,14 +859,14 @@ exports[`Setup CLI > should update custom diagnostic severities when plugin alre "dependencies": {}, "devDependencies": { "@effect/tsgo": "^0.0.5", -"typescript": "7.1.0-dev.test" + "typescript": "7.1.0-dev.test" } }" `; exports[`Setup CLI > should update custom diagnostic severities when plugin already has custom values > tsconfig.json 1`] = ` "{ - "$schema": "./node_modules/@effect/tsgo/schema.json", + "$schema": "./node_modules/@effect/tsgo/schema.json", "compilerOptions": { "strict": true, "target": "ES2022", @@ -874,10 +874,10 @@ exports[`Setup CLI > should update custom diagnostic severities when plugin alre { "name": "@effect/language-service", "diagnosticSeverity": { - "floatingEffect": "warning", - "missingEffectError": "off", - "missingLayerContext": "error" - } + "floatingEffect": "warning", + "missingEffectError": "off", + "missingLayerContext": "error" + } } ] } diff --git a/_packages/tsgo/test/setup/changes.test.ts b/_packages/tsgo/test/setup/changes.test.ts index b7564d38..b9470b06 100644 --- a/_packages/tsgo/test/setup/changes.test.ts +++ b/_packages/tsgo/test/setup/changes.test.ts @@ -985,4 +985,98 @@ describe("computeChanges", () => { expect(rendered).not.toContain("diagnosticSeverity") }) }) + + describe("JSON indentation", () => { + it.each([ + ["two spaces", " ", "\n"], + ["four spaces", " ", "\n"], + ["tabs", "\t", "\n"], + ["CRLF newlines", " ", "\r\n"] + ])("should preserve %s in package.json", (_, indentation, newLine) => { + const packageJsonText = [ + "{", + `${indentation}"name": "test-project",`, + `${indentation}"devDependencies": {`, + `${indentation.repeat(2)}"typescript": "${TEST_TYPESCRIPT_VERSION}"`, + `${indentation}}`, + "}" + ].join(newLine) + const result = runComputeChanges({ + packageJsonText, + typescriptVersion: null, + prepareScript: false, + editors: [], + vscodeTargetSettings: null + }) + const change = result.codeActions + .flatMap((action) => action.changes) + .find((change) => change.fileName === "/test/package.json") + const updated = applyTextChanges(packageJsonText, change?.textChanges ?? []) + + expect(updated).toContain(`${newLine}${indentation.repeat(2)}"@effect/tsgo": "0.0.4"`) + expect(() => JSON.parse(updated)).not.toThrow() + }) + + it.each([ + ["two spaces", " "], + ["four spaces", " "], + ["tabs", "\t"] + ])("should preserve %s in tsconfig.json", (_, indentation) => { + const tsconfigText = [ + "{", + `${indentation}"compilerOptions": {`, + `${indentation.repeat(2)}"strict": true`, + `${indentation}}`, + "}" + ].join("\n") + const result = runComputeChanges({ + tsconfigText, + prepareScript: false, + editors: [], + vscodeTargetSettings: null + }) + const change = result.codeActions + .flatMap((action) => action.changes) + .find((change) => change.fileName === "/test/tsconfig.json") + const updated = applyTextChanges(tsconfigText, change?.textChanges ?? []) + + expect(updated).toContain(`\n${indentation}"$schema"`) + expect(updated).toContain(`\n${indentation.repeat(2)}"plugins"`) + expect(updated).toContain(`\n${indentation.repeat(4)}"name": "@effect/language-service"`) + expect(() => JSON.parse(updated)).not.toThrow() + }) + + it.each([ + { layout: "inline", lines: [" \"devDependencies\": {}"] }, + { layout: "multiline", lines: [" \"devDependencies\": {", " }"] } + ])("should indent properties added to an $layout empty object", ({ lines }) => { + const packageJsonText = [ + "{", + " \"name\": \"test-project\",", + ...lines, + "}" + ].join("\n") + const result = runComputeChanges({ + packageJsonText, + prepareScript: false, + editors: [], + vscodeTargetSettings: null + }) + const change = result.codeActions + .flatMap((action) => action.changes) + .find((change) => change.fileName === "/test/package.json") + const updated = applyTextChanges(packageJsonText, change?.textChanges ?? []) + + expect(updated).toBe([ + "{", + " \"name\": \"test-project\",", + " \"devDependencies\": {", + ` "typescript": "${TEST_TYPESCRIPT_VERSION}",`, + " \"@effect/tsgo\": \"0.0.4\"", + " }", + "}" + ].join("\n")) + expect(() => JSON.parse(updated)).not.toThrow() + }) + }) }) diff --git a/_packages/tsgo/tsdown.config.ts b/_packages/tsgo/tsdown.config.ts index 15a4c973..0011161e 100644 --- a/_packages/tsgo/tsdown.config.ts +++ b/_packages/tsgo/tsdown.config.ts @@ -19,6 +19,41 @@ const copyPackageFiles = () => { const oxlintSchemaJson = yield* fs.readFileString("../../oxlint-schema.json") yield* fs.writeFileString(path.join("oxlint-schema.json"), oxlintSchemaJson) + + const presetDirectory = path.join("oxlint-presets") + yield* fs.remove(presetDirectory, { recursive: true, force: true }) + yield* fs.makeDirectory(presetDirectory) + + const presetSources: Array = [] + const presetSourceDirectory = path.join("../../oxlint-presets") + const presetFiles = (yield* fs.readDirectory(presetSourceDirectory)) + .filter((name) => name.endsWith(".json")) + .sort() + const exportNames: Array = [] + for (const presetFile of presetFiles) { + const fileName = presetFile.slice(0, -".json".length) + const exportName = fileName.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()) + const source = yield* fs.readFileString(path.join(presetSourceDirectory, presetFile)) + yield* fs.writeFileString(path.join(presetDirectory, presetFile), source) + presetSources.push(`export const ${exportName} = ${source.trim()}\n`) + exportNames.push(exportName) + } + + const generatedHeader = "// Code generated by the @effect/tsgo package build. DO NOT EDIT.\n\n" + yield* fs.writeFileString( + path.join(presetDirectory, "index.js"), + `${generatedHeader}${presetSources.join("\n")}\nexport const presets = {\n${ + exportNames.map((name) => ` ${name},`).join("\n") + }\n}\n` + ) + yield* fs.writeFileString( + path.join(presetDirectory, "index.d.ts"), + `${generatedHeader}import type { OxlintConfig } from "oxlint"\n\n${ + exportNames.map((name) => `export declare const ${name}: OxlintConfig`).join("\n") + }\n\nexport declare const presets: Readonly<{\n${ + exportNames.map((name) => ` ${name}: OxlintConfig`).join("\n") + }\n}>\n` + ) }).pipe(Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layerPosix))) return Effect.runPromise(program) diff --git a/_packages/tsgo/upstream.json b/_packages/tsgo/upstream.json index 7a55e29f..68854b16 100644 --- a/_packages/tsgo/upstream.json +++ b/_packages/tsgo/upstream.json @@ -3,10 +3,10 @@ "tags": { "typescript": { "latest": "7.0.2", - "next": "7.1.0-dev.20260805.1" + "next": "7.1.0-dev.20260811.1" }, "oxlint": { - "latest": "1.77.0" + "latest": "1.78.0" }, "oxlint-tsgolint": { "latest": "7.0.2001" @@ -17,8 +17,8 @@ "7.0.2": { "gitHead": "2bd066d87f5bafd315be9f40889d0a60b9e58e0b" }, - "7.1.0-dev.20260805.1": { - "gitHead": "12318e599d21f516defea3b20e5d44b9369da723" + "7.1.0-dev.20260811.1": { + "gitHead": "e67d5e9a7547bf6ebf26277481d53630b02283b0" } }, "oxlint-tsgolint": { @@ -33,8 +33,8 @@ "1.76.0": { "gitHead": "65fe65d8429e1d1bdf86c517ff08bd119ee87660" }, - "1.77.0": { - "gitHead": "9a423f2f485b79c2353c49442c0c7f60f900261d" + "1.78.0": { + "gitHead": "c42d6397eab5b2d5bb2bd6746c57bc2a9cad21bd" } } }, diff --git a/_patches/typescript-go/009-execute-tsc-emit.patch b/_patches/typescript-go/009-execute-tsc-emit.patch index fc7df1a2..93315751 100644 --- a/_patches/typescript-go/009-execute-tsc-emit.patch +++ b/_patches/typescript-go/009-execute-tsc-emit.patch @@ -24,14 +24,12 @@ diff --git a/internal/execute/tsc/emit.go b/internal/execute/tsc/emit.go index 3a1e3de69..8cb7c24c0 100644 --- a/internal/execute/tsc/emit.go +++ b/internal/execute/tsc/emit.go -@@ -10,6 +10,7 @@ import ( +@@ -10,4 +10,5 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" - "github.com/microsoft/typescript-go/internal/locale" - "github.com/microsoft/typescript-go/internal/tracing" @@ -17,6 +18,17 @@ import ( "github.com/microsoft/typescript-go/internal/tspath" ) @@ -67,3 +65,33 @@ index 3a1e3de69..8cb7c24c0 100644 result.Status = ExitStatusDiagnosticsPresent_OutputsGenerated } return result, statistics +diff --git a/internal/compiler/program.go b/internal/compiler/program.go +--- a/internal/compiler/program.go ++++ b/internal/compiler/program.go +@@ -30,6 +30,16 @@ import ( + "github.com/microsoft/typescript-go/internal/tracing" + "github.com/microsoft/typescript-go/internal/tsoptions" + "github.com/microsoft/typescript-go/internal/tspath" + ) + ++// FilterDiagnosticsForNoEmitOnErrorCallback is invoked before diagnostics ++// suppress emit when noEmitOnError is enabled. ++var FilterDiagnosticsForNoEmitOnErrorCallback func(*core.CompilerOptions, []*ast.Diagnostic) []*ast.Diagnostic ++ ++// RegisterFilterDiagnosticsForNoEmitOnErrorCallback registers a callback to ++// filter diagnostics used by noEmitOnError. ++func RegisterFilterDiagnosticsForNoEmitOnErrorCallback(cb func(*core.CompilerOptions, []*ast.Diagnostic) []*ast.Diagnostic) { ++ FilterDiagnosticsForNoEmitOnErrorCallback = cb ++} ++ + type ProgramOptions struct { +@@ -1743,6 +1753,9 @@ func HandleNoEmitOnError(ctx context.Context, program ProgramLike, file *ast.Sou + program.GetBindDiagnostics, + program.GetSemanticDiagnostics, + ) ++ if FilterDiagnosticsForNoEmitOnErrorCallback != nil { ++ diagnostics = FilterDiagnosticsForNoEmitOnErrorCallback(program.Options(), diagnostics) ++ } + if len(diagnostics) == 0 { + return nil // No diagnostics, so we can proceed with emitting + } diff --git a/_patches/typescript-go/028-incremental-effect-options.patch b/_patches/typescript-go/028-incremental-effect-options.patch new file mode 100644 index 00000000..3f280758 --- /dev/null +++ b/_patches/typescript-go/028-incremental-effect-options.patch @@ -0,0 +1,47 @@ +diff --git a/internal/execute/incremental/buildInfo.go b/internal/execute/incremental/buildInfo.go +index 61ae063d4..47da02331 100644 +--- a/internal/execute/incremental/buildInfo.go ++++ b/internal/execute/incremental/buildInfo.go +@@ -4,6 +4,7 @@ import ( + "fmt" + "iter" + ++ "github.com/effect-ts/tsgo/etscore" + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" +@@ -476,6 +477,7 @@ type BuildInfo struct { + FileInfos []*BuildInfoFileInfo `json:"fileInfos,omitzero"` + FileIdsList [][]BuildInfoFileId `json:"fileIdsList,omitzero"` + Options *collections.OrderedMap[string, any] `json:"options,omitzero"` ++ Effect *etscore.EffectPluginOptions `json:"effect,omitzero"` + ReferencedMap []*BuildInfoReferenceMapEntry `json:"referencedMap,omitzero"` + SemanticDiagnosticsPerFile []*BuildInfoSemanticDiagnostic `json:"semanticDiagnosticsPerFile,omitzero"` + EmitDiagnosticsPerFile []*BuildInfoDiagnosticsOfFile `json:"emitDiagnosticsPerFile,omitzero"` +@@ -529,1 +531,2 @@ ++ options.Effect = b.Effect + return options + +diff --git a/internal/execute/incremental/snapshottobuildinfo.go b/internal/execute/incremental/snapshottobuildinfo.go +index 909fab087..6595cd86c 100644 +--- a/internal/execute/incremental/snapshottobuildinfo.go ++++ b/internal/execute/incremental/snapshottobuildinfo.go +@@ -19,3 +19,4 @@ func snapshotToBuildInfo(snapshot *snapshot, program *compiler.Program, buildInfo + buildInfo := &BuildInfo{ + Version: core.Version(), ++ Effect: snapshot.options.Effect, + } +diff --git a/internal/tsoptions/declscompiler.go b/internal/tsoptions/declscompiler.go +index c6a70ff9e..fd1672ea7 100644 +--- a/internal/tsoptions/declscompiler.go ++++ b/internal/tsoptions/declscompiler.go +@@ -1243,6 +1243,9 @@ func CompilerOptionsAffectSemanticDiagnostics( + oldOptions *core.CompilerOptions, + newOptions *core.CompilerOptions, + ) bool { ++ if !reflect.DeepEqual(oldOptions.Effect, newOptions.Effect) { ++ return true ++ } + return optionsHaveChanges(oldOptions, newOptions, func(option *CommandLineOption) bool { + return option.AffectsSemanticDiagnostics + }) diff --git a/_tools/deadcode-allow.txt b/_tools/deadcode-allow.txt new file mode 100644 index 00000000..3fc740c2 --- /dev/null +++ b/_tools/deadcode-allow.txt @@ -0,0 +1,7 @@ +# Entries ending in / match finding path prefixes; other entries match exact function names. + +# Public Go API consumed by integrations outside the tsgo executable. +etsgoapi/ + +# Public adapter consumed by the generated Oxlint integration. +etsoxlintrunner/ diff --git a/_tools/oxlint-configuration-base-schema.json b/_tools/oxlint-configuration-base-schema.json index 022feed3..a37652d8 100644 --- a/_tools/oxlint-configuration-base-schema.json +++ b/_tools/oxlint-configuration-base-schema.json @@ -672,6 +672,21 @@ }, "additionalProperties": false }, + "AnchorHasContentConfig": { + "type": "object", + "properties": { + "components": { + "description": "Additional custom component names to treat as anchor elements.", + "default": [], + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Additional custom component names to treat as anchor elements." + } + }, + "additionalProperties": false + }, "AnchorIsValidAspect": { "type": "string", "enum": [ @@ -2034,6 +2049,15 @@ }, "additionalProperties": false }, + "DescriptionFormatConfig": { + "type": "object", + "properties": { + "descriptionFormat": { + "type": "string" + } + }, + "additionalProperties": false + }, "Destructure": { "oneOf": [ { @@ -2091,13 +2115,7 @@ "$ref": "#/definitions/RequireDescription" }, { - "type": "object", - "properties": { - "descriptionFormat": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/definitions/DescriptionFormatConfig" } ] }, @@ -3563,6 +3581,26 @@ "jsdoc/implements-on-classes": { "$ref": "#/definitions/RuleNoConfig" }, + "jsdoc/no-blank-blocks": { + "anyOf": [ + { + "$ref": "#/definitions/RuleNoConfig" + }, + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/AllowWarnDeny" + }, + { + "$ref": "#/definitions/NoBlankBlocks" + } + ], + "maxItems": 2, + "minItems": 2 + } + ] + }, "jsdoc/no-defaults": { "anyOf": [ { @@ -3757,7 +3795,24 @@ ] }, "jsx-a11y/anchor-has-content": { - "$ref": "#/definitions/RuleNoConfig" + "anyOf": [ + { + "$ref": "#/definitions/RuleNoConfig" + }, + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/AllowWarnDeny" + }, + { + "$ref": "#/definitions/AnchorHasContentConfig" + } + ], + "maxItems": 2, + "minItems": 2 + } + ] }, "jsx-a11y/anchor-is-valid": { "anyOf": [ @@ -5850,6 +5905,26 @@ } ] }, + "one-var": { + "anyOf": [ + { + "$ref": "#/definitions/RuleNoConfig" + }, + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/AllowWarnDeny" + }, + { + "$ref": "#/definitions/OneVar" + } + ], + "maxItems": 2, + "minItems": 2 + } + ] + }, "operator-assignment": { "anyOf": [ { @@ -13715,6 +13790,18 @@ }, "additionalProperties": false }, + "NoBlankBlocks": { + "type": "object", + "properties": { + "enableFixer": { + "description": "Whether to automatically remove blank JSDoc blocks.", + "default": false, + "type": "boolean", + "markdownDescription": "Whether to automatically remove blank JSDoc blocks." + } + }, + "additionalProperties": false + }, "NoCallbackInPromiseConfig": { "type": "object", "properties": { @@ -16584,6 +16671,153 @@ } ] }, + "OneVar": { + "description": "Enforces consistent grouping of variable declarations.", + "allOf": [ + { + "$ref": "#/definitions/OneVarConfig" + } + ], + "markdownDescription": "Enforces consistent grouping of variable declarations." + }, + "OneVarConfig": { + "description": "Configuration accepted by the `one-var` rule.", + "anyOf": [ + { + "description": "Applies one grouping mode to every declaration kind and initialization state.", + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Applies one grouping mode to every declaration kind and initialization state." + }, + { + "description": "Configures grouping by declaration kind or initialization state.", + "allOf": [ + { + "$ref": "#/definitions/OneVarOptions" + } + ], + "markdownDescription": "Configures grouping by declaration kind or initialization state." + } + ], + "markdownDescription": "Configuration accepted by the `one-var` rule." + }, + "OneVarMode": { + "description": "Controls how variable declarators are grouped into declarations.", + "oneOf": [ + { + "description": "Requires one declaration per variable kind in each applicable scope.", + "type": "string", + "enum": [ + "always" + ], + "markdownDescription": "Requires one declaration per variable kind in each applicable scope." + }, + { + "description": "Requires each declarator to have its own declaration statement.", + "type": "string", + "enum": [ + "never" + ], + "markdownDescription": "Requires each declarator to have its own declaration statement." + }, + { + "description": "Requires adjacent declarations of the same kind to be combined.", + "type": "string", + "enum": [ + "consecutive" + ], + "markdownDescription": "Requires adjacent declarations of the same kind to be combined." + } + ], + "markdownDescription": "Controls how variable declarators are grouped into declarations." + }, + "OneVarOptions": { + "description": "Options for configuring declaration grouping by kind or initialization state.\n\n`initialized` and `uninitialized` take precedence over the per-kind option for the\ncorresponding declarators.", + "type": "object", + "properties": { + "awaitUsing": { + "description": "Controls grouping for `await using` declarations.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for `await using` declarations." + }, + "const": { + "description": "Controls grouping for `const` declarations.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for `const` declarations." + }, + "initialized": { + "description": "Controls grouping for initialized declarators, overriding per-kind options.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for initialized declarators, overriding per-kind options." + }, + "let": { + "description": "Controls grouping for `let` declarations.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for `let` declarations." + }, + "separateRequires": { + "description": "Keeps direct `require(...)` initializers separate from other initialized declarations.", + "default": false, + "type": "boolean", + "markdownDescription": "Keeps direct `require(...)` initializers separate from other initialized declarations." + }, + "uninitialized": { + "description": "Controls grouping for uninitialized declarators, overriding per-kind options.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for uninitialized declarators, overriding per-kind options." + }, + "using": { + "description": "Controls grouping for `using` declarations.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for `using` declarations." + }, + "var": { + "description": "Controls grouping for `var` declarations.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OneVarMode" + } + ], + "markdownDescription": "Controls grouping for `var` declarations." + } + }, + "additionalProperties": false, + "markdownDescription": "Options for configuring declaration grouping by kind or initialization state.\n\n`initialized` and `uninitialized` take precedence over the per-kind option for the\ncorresponding declarators." + }, "OnlyExportComponentsConfig": { "type": "object", "properties": { diff --git a/_tools/repoctl/package.json b/_tools/repoctl/package.json index e051219c..e8b20e0d 100644 --- a/_tools/repoctl/package.json +++ b/_tools/repoctl/package.json @@ -11,8 +11,8 @@ "test": "node --test test/*.test.ts" }, "dependencies": { - "@effect/platform-node": "^4.0.0-beta.104", - "effect": "^4.0.0-beta.104", + "@effect/platform-node": "^4.0.0-beta.107", + "effect": "^4.0.0-beta.107", "semver": "^7.7.2" }, "devDependencies": { diff --git a/_tools/repoctl/src/lint.ts b/_tools/repoctl/src/lint.ts new file mode 100644 index 00000000..4e36207d --- /dev/null +++ b/_tools/repoctl/src/lint.ts @@ -0,0 +1,78 @@ +import * as Console from "effect/Console" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Path from "effect/Path" +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner" +import { CommandError, runCommand, runCommandCaptureSplit } from "./process.ts" + +const deadcodeVersion = "v0.48.0" + +export class DeadCodeError extends Data.TaggedError("DeadCodeError")<{ + readonly findings: ReadonlyArray +}> { + get message(): string { + return [ + ...this.findings, + "", + "deadcode: the functions above are unreachable from the tsgo executable and all tests.", + "Delete them, or add the function name or package path prefix to _tools/deadcode-allow.txt with a reason." + ].join("\n") + } +} + +export const parseDeadCodeAllowlist = (content: string): ReadonlySet => + new Set(content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"))) + +export const filterDeadCodeFindings = ( + output: string, + allowlist: ReadonlySet +): ReadonlyArray => + output.split("\n").map((line) => line.trim()).filter((line) => { + const normalizedLine = line.replaceAll("\\", "/") + if (normalizedLine === "" || normalizedLine.includes("_test.go:")) { + return false + } + if ([...allowlist].some((entry) => entry.endsWith("/") && normalizedLine.startsWith(entry))) { + return false + } + const marker = "unreachable func: " + const markerIndex = normalizedLine.indexOf(marker) + return markerIndex === -1 || !allowlist.has(normalizedLine.slice(markerIndex + marker.length)) + }) + +export const runDeadCode = Effect.fnUntraced(function*(repositoryRoot: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const allowlist = parseDeadCodeAllowlist( + yield* fs.readFileString(path.join(repositoryRoot, "_tools", "deadcode-allow.txt")) + ) + const args = [ + "run", + `golang.org/x/tools/cmd/deadcode@${deadcodeVersion}`, + "-test", + "-filter=^github.com/effect-ts/tsgo($|/)", + "./typescript-go/cmd/tsgo", + "./..." + ] + const result = yield* runCommandCaptureSplit("go", repositoryRoot, args, { CGO_ENABLED: "0" }) + if (result.exitCode !== ChildProcessSpawner.ExitCode(0)) { + return yield* new CommandError({ + command: "go", + args, + exitCode: result.exitCode, + stderr: result.stderr + }) + } + + const findings = filterDeadCodeFindings(result.stdout, allowlist) + if (findings.length > 0) { + return yield* new DeadCodeError({ findings }) + } + yield* Console.log("deadcode: clean") +}) + +export const runLint = Effect.fnUntraced(function*(repositoryRoot: string) { + yield* runCommand("golangci-lint", repositoryRoot, ["run", "./..."], false, { CGO_ENABLED: "0" }) + yield* runDeadCode(repositoryRoot) +}) diff --git a/_tools/repoctl/src/main.ts b/_tools/repoctl/src/main.ts index 1fd05986..2e5e82c0 100755 --- a/_tools/repoctl/src/main.ts +++ b/_tools/repoctl/src/main.ts @@ -19,6 +19,7 @@ import { import { ensureEffectFixtures } from "./fixtures.ts" import { updateFlake } from "./flake.ts" import { completeCheck, openPullRequestIfChanged } from "./github.ts" +import { runLint } from "./lint.ts" import { printGeneratedMatrix, printOxlintTestMatrix, @@ -94,6 +95,10 @@ const check = Command.make("check", {}, () => runChecks(repositoryRoot)).pipe( Command.withDescription("Check Go packages followed by the CLI package") ) +const lint = Command.make("lint", {}, () => runLint(repositoryRoot)).pipe( + Command.withDescription("Run Go linters and dead-code analysis") +) + const buildLocalCommand = Command.make("local", {}, () => buildLocal(repositoryRoot)).pipe( Command.withDescription("Build the local Go binary and CLI package") ) @@ -316,6 +321,7 @@ Command.make("repoctl").pipe( codegen, flake, github, + lint, matrix, packages, perf, diff --git a/_tools/repoctl/src/upstream.ts b/_tools/repoctl/src/upstream.ts index b91815ca..466ffd57 100644 --- a/_tools/repoctl/src/upstream.ts +++ b/_tools/repoctl/src/upstream.ts @@ -174,6 +174,11 @@ interface TypeScriptMetadata { readonly gitHead: string } +interface TypeScriptGoCommit { + readonly sha: string + readonly message: string +} + interface OxlintSelection { readonly oxlintVersion: string readonly tsgolintVersion: string @@ -309,9 +314,15 @@ export const formatOxlintConfigurationSchema = (value: unknown): string | undefi return `${JSON.stringify(value, null, 2)}\n` } -const fetchJson = Effect.fnUntraced(function*(url: string) { +const fetchJson = Effect.fnUntraced(function*(url: string, authenticate = false) { + const token = authenticate ? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN : undefined const response = yield* Effect.tryPromise({ - try: () => fetch(url, { headers: { "User-Agent": "effect-tsgo-repoctl" } }), + try: () => fetch(url, { + headers: { + "User-Agent": "effect-tsgo-repoctl", + ...(token === undefined ? {} : { Authorization: `Bearer ${token}` }) + } + }), catch: (error) => new UpstreamManifestError({ reason: `Unable to fetch ${url}: ${String(error)}` }) }) if (!response.ok) { @@ -323,6 +334,169 @@ const fetchJson = Effect.fnUntraced(function*(url: string) { }) }) +const GitHubComparison = Schema.Struct({ + total_commits: Schema.Number, + commits: Schema.Array(Schema.Struct({ + sha: GitRevision, + commit: Schema.Struct({ message: NonEmptyString }) + })) +}) + +const fetchTypeScriptGoCommits = Effect.fnUntraced(function*(before: string, after: string) { + if (before === after) { + return [] + } + const commits: Array = [] + let page = 1 + let totalCommits = 0 + do { + const url = [ + `https://api.github.com/repos/microsoft/typescript-go/compare/${before}...${after}`, + `?per_page=100&page=${page}` + ].join("") + const comparison = yield* fetchJson(url, true) + const decoded = yield* Schema.decodeUnknownEffect(GitHubComparison)(comparison).pipe( + Effect.mapError((error) => new UpstreamManifestError({ + reason: `Unable to read TypeScript-Go comparison: ${error.message}` + })) + ) + totalCommits = decoded.total_commits + if (decoded.commits.length === 0 && commits.length < totalCommits) { + return yield* new UpstreamManifestError({ reason: "TypeScript-Go comparison ended before all commits were read" }) + } + commits.push(...decoded.commits.map(({ commit, sha }) => ({ + sha, + message: commit.message.split(/[\r\n]/, 1)[0]! + }))) + page++ + } while (commits.length < totalCommits) + return commits +}) + +export interface UpstreamUpdateDescriptionOptions { + readonly before: typeof Upstream.Type + readonly after: typeof Upstream.Type + readonly nextCommits: ReadonlyArray + readonly schemaChanged: boolean + readonly oxlintSchemaChanged: boolean +} + +export const formatUpstreamUpdateDescription = ({ + after, + before, + nextCommits, + oxlintSchemaChanged, + schemaChanged +}: UpstreamUpdateDescriptionOptions): string => { + const beforeVitePlus = before.profiles.find(({ name }) => name === "vite-plus")! + const afterVitePlus = after.profiles.find(({ name }) => name === "vite-plus")! + const beforeVitePlusVersion = /^Vite\+ (.+) compatibility runtime$/.exec(beforeVitePlus.description)?.[1] + const afterVitePlusVersion = /^Vite\+ (.+) compatibility runtime$/.exec(afterVitePlus.description)?.[1] + const versionUpdates = [ + { + label: "TypeScript next", + packageName: "typescript", + spec: "typescript@next", + previous: before.tags.typescript.next, + updated: after.tags.typescript.next + }, + { + label: "TypeScript latest", + packageName: "typescript", + spec: "typescript@latest", + previous: before.tags.typescript.latest, + updated: after.tags.typescript.latest + }, + { + label: "Oxlint", + packageName: "oxlint", + spec: "oxlint@latest", + previous: before.tags.oxlint.latest, + updated: after.tags.oxlint.latest + }, + { + label: "Oxlint TypeScript-Go lint plugin", + packageName: "oxlint-tsgolint", + spec: "oxlint-tsgolint@latest", + previous: before.tags["oxlint-tsgolint"].latest, + updated: after.tags["oxlint-tsgolint"].latest + }, + { + label: "Vite+", + packageName: "vite-plus", + spec: "vite-plus@latest", + previous: beforeVitePlusVersion, + updated: afterVitePlusVersion + }, + { + label: "Vite+ Oxlint runtime", + packageName: "oxlint", + spec: "oxlint", + previous: beforeVitePlus.dependencies.oxlint, + updated: afterVitePlus.dependencies.oxlint + }, + { + label: "Vite+ TypeScript-Go lint runtime", + packageName: "oxlint-tsgolint", + spec: "oxlint-tsgolint", + previous: beforeVitePlus.dependencies["oxlint-tsgolint"], + updated: afterVitePlus.dependencies["oxlint-tsgolint"] + } + ].filter((update): update is typeof update & { readonly previous: string; readonly updated: string } => + update.previous !== undefined && update.updated !== undefined && update.previous !== update.updated) + const previousNext = before.components.typescript[before.tags.typescript.next]!.gitHead + const updatedNext = after.components.typescript[after.tags.typescript.next]!.gitHead + const sections = ["Automated update of upstream metadata, generated TypeScript next-tag sources, and Nix inputs."] + + if (versionUpdates.length > 0) { + sections.push([ + "## Version updates", + "", + ...versionUpdates.map(({ label, packageName, previous, spec, updated }) => + `- ${label}: [\`${spec}\`](https://www.npmjs.com/package/${packageName}/v/${updated}) \`${previous}\` -> \`${updated}\``) + ].join("\n")) + } + if (previousNext !== updatedNext) { + sections.push([ + "## TypeScript-Go", + "", + `- Previous commit: [\`${previousNext}\`](https://github.com/microsoft/typescript-go/commit/${previousNext})`, + `- Updated commit: [\`${updatedNext}\`](https://github.com/microsoft/typescript-go/commit/${updatedNext})`, + `- Compare: https://github.com/microsoft/typescript-go/compare/${previousNext}...${updatedNext}` + ].join("\n")) + } + if (nextCommits.length > 0) { + sections.push([ + "## Upstream commits", + "", + ...nextCommits.map(({ message, sha }) => + `- [${sha.slice(0, 7)}](https://github.com/microsoft/typescript-go/commit/${sha}) ${message}`) + ].join("\n")) + } + const otherUpdates = [ + ...(schemaChanged ? ["- Refreshed the tsconfig schema from JSON Schema Store."] : []), + ...(oxlintSchemaChanged ? ["- Refreshed the Oxlint configuration schema from the selected package."] : []) + ] + if (otherUpdates.length > 0) { + sections.push(["## Other updates", "", ...otherUpdates].join("\n")) + } + return sections.join("\n\n") +} + +export const formatGitHubOutputs = (outputs: Readonly>) => + Object.entries(outputs).map(([name, value]) => { + const normalizedValue = value.replace(/\r\n?/g, "\n") + if (!normalizedValue.includes("\n")) { + return `${name}=${normalizedValue}\n` + } + let delimiter = `repoctl_${name}` + const lines = new Set(normalizedValue.split("\n")) + while (lines.has(delimiter)) { + delimiter += "_" + } + return `${name}<<${delimiter}\n${normalizedValue}\n${delimiter}\n` + }).join("") + const resolveRemoteTag = Effect.fnUntraced(function*(repositoryRoot: string, repository: string, tag: string) { const output = yield* runCommandString("git", repositoryRoot, [ "ls-remote", @@ -538,6 +712,15 @@ export const updateUpstream = Effect.fnUntraced(function*(repositoryRoot: string const oxlintSchemaChanged = oxlintSchema !== currentOxlintSchema const hasChanges = metadataChanged || schemaChanged || oxlintSchemaChanged + const nextCommits = yield* fetchTypeScriptGoCommits(nextBefore.gitHead, next.gitHead) + const description = formatUpstreamUpdateDescription({ + before: upstream, + after: updated, + nextCommits, + schemaChanged, + oxlintSchemaChanged + }) + if (metadataChanged) { yield* fs.writeFileString( path.join(repositoryRoot, "_packages", "tsgo", "upstream.json"), @@ -568,12 +751,13 @@ export const updateUpstream = Effect.fnUntraced(function*(repositoryRoot: string latest_previous_version: latestBefore.npmVersion, latest_previous_git_head: latestBefore.gitHead, latest_version: latest.npmVersion, - latest_git_head: latest.gitHead + latest_git_head: latest.gitHead, + description } if (process.env.GITHUB_OUTPUT !== undefined) { yield* Effect.tryPromise(() => appendFile( process.env.GITHUB_OUTPUT!, - Object.entries(outputs).map(([name, value]) => `${name}=${value}\n`).join("") + formatGitHubOutputs(outputs) )) } yield* Effect.log(hasChanges ? "Updated upstream metadata" : "Upstream metadata is current") diff --git a/_tools/repoctl/test/lint.test.ts b/_tools/repoctl/test/lint.test.ts new file mode 100644 index 00000000..404f73e3 --- /dev/null +++ b/_tools/repoctl/test/lint.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { filterDeadCodeFindings, parseDeadCodeAllowlist } from "../src/lint.ts" + +test("parses documented dead-code exceptions", () => { + assert.deepEqual( + [...parseDeadCodeAllowlist("# reason\netsgoapi/\n\nOther\n")], + ["etsgoapi/", "Other"] + ) +}) + +test("filters external APIs, tests, and allowed functions", () => { + const findings = filterDeadCodeFindings([ + "etsgoapi/type_parser.go:1:1: unreachable func: PublicAPI", + "etsgoapi\\type_parser.go:2:1: unreachable func: WindowsPublicAPI", + "etsoxlintrunner/runner.go:1:1: unreachable func: RunRule", + "internal/example/example_test.go:1:1: unreachable func: testHelper", + "internal/example/example.go:1:1: unreachable func: Allowed", + "internal/example/example.go:2:1: unreachable func: RemoveMe", + "" + ].join("\n"), new Set(["etsgoapi/", "etsoxlintrunner/", "Allowed"])) + + assert.deepEqual(findings, ["internal/example/example.go:2:1: unreachable func: RemoveMe"]) +}) diff --git a/_tools/repoctl/test/upstream.test.ts b/_tools/repoctl/test/upstream.test.ts index 5ab51753..bf65111a 100644 --- a/_tools/repoctl/test/upstream.test.ts +++ b/_tools/repoctl/test/upstream.test.ts @@ -6,8 +6,10 @@ import { decodeLatestNpmVersion, decodeUpstream, findTypeScriptVersion, + formatGitHubOutputs, formatOxlintConfigurationSchema, formatTSConfigSchema, + formatUpstreamUpdateDescription, getComponent } from "../src/upstream.ts" import { resolveUpstreamInfo } from "../src/upstreamResolve.ts" @@ -17,7 +19,7 @@ const secondRevision = "1123456789abcdef0123456789abcdef01234567" const thirdRevision = "2123456789abcdef0123456789abcdef01234567" const manifest = () => ({ - schemaVersion: 4, + schemaVersion: 4 as const, tags: { typescript: { latest: "7.0.0", @@ -208,6 +210,75 @@ test("finds a TypeScript npm version by its git head", () => { }, secondRevision), "7.0.2") }) +test("describes upstream version and TypeScript-Go commit updates", () => { + const before = manifest() + const after = manifest() + const afterTypeScript = after.components.typescript as Record + const afterOxlint = after.components.oxlint as Record + after.tags.typescript.next = "7.2.0" + afterTypeScript["7.2.0"] = { gitHead: thirdRevision } + after.tags.oxlint.latest = "1.2.0" + afterOxlint["1.2.0"] = { gitHead: thirdRevision } + after.profiles[0]!.description = "Vite+ 1.1.0 compatibility runtime" + after.profiles[0]!.dependencies.oxlint = "1.2.0" + + assert.equal(formatUpstreamUpdateDescription({ + before, + after, + nextCommits: [{ sha: thirdRevision, message: "Add a useful feature" }], + schemaChanged: true, + oxlintSchemaChanged: false + }), [ + "Automated update of upstream metadata, generated TypeScript next-tag sources, and Nix inputs.", + "", + "## Version updates", + "", + "- TypeScript next: [`typescript@next`](https://www.npmjs.com/package/typescript/v/7.2.0) `7.1.0` -> `7.2.0`", + "- Oxlint: [`oxlint@latest`](https://www.npmjs.com/package/oxlint/v/1.2.0) `1.1.0` -> `1.2.0`", + "- Vite+: [`vite-plus@latest`](https://www.npmjs.com/package/vite-plus/v/1.1.0) `1.0.0` -> `1.1.0`", + "- Vite+ Oxlint runtime: [`oxlint`](https://www.npmjs.com/package/oxlint/v/1.2.0) `1.0.0` -> `1.2.0`", + "", + "## TypeScript-Go", + "", + `- Previous commit: [\`${secondRevision}\`](https://github.com/microsoft/typescript-go/commit/${secondRevision})`, + `- Updated commit: [\`${thirdRevision}\`](https://github.com/microsoft/typescript-go/commit/${thirdRevision})`, + `- Compare: https://github.com/microsoft/typescript-go/compare/${secondRevision}...${thirdRevision}`, + "", + "## Upstream commits", + "", + `- [${thirdRevision.slice(0, 7)}](https://github.com/microsoft/typescript-go/commit/${thirdRevision}) Add a useful feature`, + "", + "## Other updates", + "", + "- Refreshed the tsconfig schema from JSON Schema Store." + ].join("\n")) +}) + +test("writes multiline descriptions as GitHub step outputs", () => { + assert.equal(formatGitHubOutputs({ has_changes: "true", description: "first\nsecond" }), [ + "has_changes=true", + "description< { assert.equal(formatTSConfigSchema({ definitions: {}, title: "tsconfig" }), [ "{", diff --git a/docs/README.md b/docs/README.md index 88ceb9f6..cc7cc401 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,24 +18,67 @@ Update the scripts section of your `package.json` to include the following: ``` This will patch Oxlint to use the Effect TypeScript-Go integration after any package installation. To avoid patching TypeScript, you can use the `--no-typescript` flag: `effect-tsgo patch --no-typescript --oxlint`. This will patch Oxlint to use the Effect TypeScript-Go integration without patching TypeScript. +## LSP diagnostics + +When you have the Effect LSP enabled as well, we recommend setting `diagnostics` to `false` in the LSP plugin settings so that Effect diagnostics are reported only by Oxlint and do not appear twice: + +```jsonc +{ + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "diagnostics": false + } + ] + } +} +``` + Run pnpm install to install the dependencies and run the prepare script to patch Oxlint. ``` pnpm install ``` -Effect rules require Oxlint's type-aware mode and the `effecttsgo` plugin. We recommend enabling both in `.oxlintrc.json` and using the schema shipped with `@effect/tsgo` for validation and completions: +Effect rules require Oxlint's type-aware mode and the `effecttsgo` plugin. The recommended preset enables both and configures the recommended Effect rules. Use the schema shipped with `@effect/tsgo` for validation and completions: ```json { "$schema": "./node_modules/@effect/tsgo/oxlint-schema.json", - "options": { - "typeAware": true - }, - "plugins": ["effecttsgo"] + "extends": [ + "./node_modules/@effect/tsgo/oxlint-presets/recommended.json" + ] } ``` +The package also provides presets for each diagnostic category: `correctness`, `antipattern`, `effect-native`, and `style`. Extended configurations are applied in order, and rules in the project configuration take precedence, so categories can be combined and individual rules can be adjusted: + +```json +{ + "$schema": "./node_modules/@effect/tsgo/oxlint-schema.json", + "extends": [ + "./node_modules/@effect/tsgo/oxlint-presets/correctness.json", + "./node_modules/@effect/tsgo/oxlint-presets/effect-native.json" + ], + "rules": { + "effecttsgo/global-date": "error", + "effecttsgo/global-console": "off" + } +} +``` + +With `oxlint.config.ts`, import the same configurations from the package: + +```ts +import { recommended } from "@effect/tsgo/oxlint-presets" +import { defineConfig } from "oxlint" + +export default defineConfig({ + extends: [recommended] +}) +``` + You can then run Oxlint normally: ```sh diff --git a/etscheckerhooks/init.go b/etscheckerhooks/init.go index 255b70e9..823cf13d 100644 --- a/etscheckerhooks/init.go +++ b/etscheckerhooks/init.go @@ -32,7 +32,8 @@ func getEffectConfig(p checker.Program) *etscore.EffectPluginOptions { // afterCheckSourceFile is called after type checking each source file. // It runs Effect diagnostics if the plugin is enabled. func afterCheckSourceFile(ctx context.Context, program checker.Program, c *checker.Checker, sf *ast.SourceFile) { - diagnostics, err := rulerunner.Run(ctx, program, c, sf, getEffectConfig(program), nil) + effectConfig := getEffectConfig(program) + diagnostics, err := rulerunner.Run(ctx, program, c, sf, effectConfig, nil, rulerunner.MinVisibleSeverity(effectConfig)) if err != nil { return } diff --git a/etscore/oxlint_schema_test.go b/etscore/oxlint_schema_test.go index 106be87f..44461746 100644 --- a/etscore/oxlint_schema_test.go +++ b/etscore/oxlint_schema_test.go @@ -6,9 +6,11 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "testing" + "github.com/effect-ts/tsgo/etscore" "github.com/effect-ts/tsgo/internal/rules" "github.com/microsoft/typescript-go/shim/testutil/baseline" ) @@ -45,6 +47,145 @@ func TestGenerateOxlintSchemaMatchesReference(t *testing.T) { t.Fatalf("oxlint-schema.json is out of date:\n%s", strings.Join(diffLines, "\n")) } +func TestGenerateOxlintPresetsMatchReferences(t *testing.T) { + root := repoRoot(t) + presets, err := generateOxlintPresets() + if err != nil { + t.Fatalf("generateOxlintPresets() error = %v", err) + } + + referenceDirectory := filepath.Join(root, "oxlint-presets") + localDirectory := filepath.Join(root, "testdata", "baselines", "local", "oxlint-presets") + update := os.Getenv("UPDATE_OXLINT_PRESETS") == "1" + + for name, actual := range presets { + referencePath := filepath.Join(referenceDirectory, name) + if update { + writeIfChanged(t, referencePath, actual) + continue + } + + expected, err := os.ReadFile(referencePath) + if err != nil { + t.Fatalf("failed to read reference preset %q: %v", referencePath, err) + } + if bytes.Equal(actual, expected) { + continue + } + + localPath := filepath.Join(localDirectory, name) + writeIfChanged(t, localPath, actual) + diff := baseline.DiffText(referencePath, localPath, string(expected), string(actual)) + t.Fatalf("%s is out of date:\n%s", referencePath, diff) + } + + entries, err := os.ReadDir(referenceDirectory) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("failed to read preset directory %q: %v", referenceDirectory, err) + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + if _, ok := presets[entry.Name()]; ok { + continue + } + path := filepath.Join(referenceDirectory, entry.Name()) + if update { + if err := os.Remove(path); err != nil { + t.Fatalf("failed to remove stale preset %q: %v", path, err) + } + continue + } + t.Fatalf("stale Oxlint preset %q", path) + } +} + +type oxlintPreset struct { + Options oxlintPresetOptions `json:"options"` + Plugins []string `json:"plugins"` + Rules map[string]string `json:"rules"` +} + +type oxlintPresetOptions struct { + TypeAware bool `json:"typeAware"` +} + +func generateOxlintPresets() (map[string][]byte, error) { + groups := rules.MetadataGroups() + generated := make(map[string][]byte, len(groups)+1) + + recommendedSeverities := make(map[string]string) + for _, current := range rules.All { + if severity := oxlintSeverity(current.DefaultSeverity); severity != "off" { + recommendedSeverities[oxlintRuleName(current.Name)] = severity + } + } + for _, preset := range rules.MetadataPresets() { + for name, severity := range preset.DiagnosticSeverity { + oxlintName := oxlintRuleName(name) + if current, next := recommendedSeverities[oxlintName], oxlintSeverity(severity); current != "error" && next != "off" { + recommendedSeverities[oxlintName] = next + } + } + } + if err := addOxlintPreset(generated, "recommended.json", recommendedSeverities); err != nil { + return nil, err + } + + for _, group := range groups { + severities := make(map[string]string) + for _, current := range rules.All { + if current.Group == group.ID { + severities[oxlintRuleName(current.Name)] = "warn" + } + } + if err := addOxlintPreset(generated, oxlintRuleName(group.ID)+".json", severities); err != nil { + return nil, err + } + } + + return generated, nil +} + +func addOxlintPreset(generated map[string][]byte, name string, severities map[string]string) error { + if _, exists := generated[name]; exists { + return fmt.Errorf("duplicate Oxlint preset %q", name) + } + ruleNames := make([]string, 0, len(severities)) + for name := range severities { + ruleNames = append(ruleNames, name) + } + slices.Sort(ruleNames) + configuredRules := make(map[string]string, len(ruleNames)) + for _, name := range ruleNames { + configuredRules["effecttsgo/"+name] = severities[name] + } + content, err := json.MarshalIndent(oxlintPreset{ + Options: oxlintPresetOptions{TypeAware: true}, + Plugins: []string{"effecttsgo"}, + Rules: configuredRules, + }, "", " ") + if err != nil { + return err + } + generated[name] = append(content, '\n') + return nil +} + +func oxlintSeverity(severity etscore.Severity) string { + switch severity { + case etscore.SeverityOff, etscore.SeveritySkipFile: + return "off" + case etscore.SeverityError: + return "error" + case etscore.SeverityWarning, etscore.SeveritySuggestion, etscore.SeverityMessage: + return "warn" + default: + panic(fmt.Sprintf("unsupported Effect severity %q", severity.String())) + } +} + func generateOxlintSchema() ([]byte, error) { root := repoRootForGeneration() baseSchemaContent, err := os.ReadFile(filepath.Join(root, "_tools", "oxlint-configuration-base-schema.json")) diff --git a/etscore/severity.go b/etscore/severity.go index 09ecb42e..cf8214ac 100644 --- a/etscore/severity.go +++ b/etscore/severity.go @@ -63,6 +63,32 @@ func (s Severity) IsOff() bool { return s == SeverityOff || s == SeveritySkipFile } +// visibilityRank orders severities by how prominently they surface in output: +// error > warning > suggestion > message > off/skip-file. This is distinct +// from the declaration order of the constants, which is not a visibility +// ordering. +func (s Severity) visibilityRank() int { + switch s { + case SeverityError: + return 4 + case SeverityWarning: + return 3 + case SeveritySuggestion: + return 2 + case SeverityMessage: + return 1 + default: + return 0 + } +} + +// AtLeastAsVisibleAs reports whether s surfaces at least as prominently as +// min. Off and skip-file severities are never at least as visible as any +// enabled severity. +func (s Severity) AtLeastAsVisibleAs(min Severity) bool { + return s.visibilityRank() >= min.visibilityRank() +} + // MarshalJSON implements json.Marshaler for Severity. // Serializes as the string representation (e.g., "error", "warning"). func (s Severity) MarshalJSON() ([]byte, error) { diff --git a/etscore/severity_test.go b/etscore/severity_test.go new file mode 100644 index 00000000..9600d884 --- /dev/null +++ b/etscore/severity_test.go @@ -0,0 +1,38 @@ +package etscore + +import "testing" + +func TestSeverityAtLeastAsVisibleAs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + severity Severity + min Severity + expected bool + }{ + {"error meets warning threshold", SeverityError, SeverityWarning, true}, + {"warning meets warning threshold", SeverityWarning, SeverityWarning, true}, + {"suggestion below warning threshold", SeveritySuggestion, SeverityWarning, false}, + {"message below warning threshold", SeverityMessage, SeverityWarning, false}, + {"off below warning threshold", SeverityOff, SeverityWarning, false}, + {"skip-file below warning threshold", SeveritySkipFile, SeverityWarning, false}, + {"error meets message threshold", SeverityError, SeverityMessage, true}, + {"warning meets message threshold", SeverityWarning, SeverityMessage, true}, + {"suggestion meets message threshold", SeveritySuggestion, SeverityMessage, true}, + {"message meets message threshold", SeverityMessage, SeverityMessage, true}, + {"off below message threshold", SeverityOff, SeverityMessage, false}, + {"suggestion below error threshold", SeveritySuggestion, SeverityError, false}, + {"warning below error threshold", SeverityWarning, SeverityError, false}, + {"error meets error threshold", SeverityError, SeverityError, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.severity.AtLeastAsVisibleAs(tt.min); got != tt.expected { + t.Errorf("%v.AtLeastAsVisibleAs(%v) = %v, want %v", tt.severity, tt.min, got, tt.expected) + } + }) + } +} diff --git a/etscore/version_generated.go b/etscore/version_generated.go index a40765fb..9d4ed496 100644 --- a/etscore/version_generated.go +++ b/etscore/version_generated.go @@ -2,4 +2,4 @@ package etscore -const EffectVersion = "0.33.0" +const EffectVersion = "0.36.4" diff --git a/etsexecutehooks/doc.go b/etsexecutehooks/doc.go index 57eacfa0..6e4ed1f2 100644 --- a/etsexecutehooks/doc.go +++ b/etsexecutehooks/doc.go @@ -1,11 +1,11 @@ -// Package etsexecutehooks provides exit-code filtering for Effect diagnostics. -// -// This package registers a FilterDiagnosticsForExitCodeCallback that filters out -// Effect diagnostics from exit-code determination based on the -// IgnoreEffectSuggestionsInTscExitCode and IgnoreEffectWarningsInTscExitCode -// configuration options. -// -// Import this package with a blank import in the main entry point: -// -// import _ "github.com/effect-ts/tsgo/etsexecutehooks" -package etsexecutehooks +// Package etsexecutehooks provides command-line filtering for Effect diagnostics. +// +// This package registers a FilterDiagnosticsForExitCodeCallback that filters out +// Effect diagnostics from exit-code and noEmitOnError determination based on the +// IgnoreEffectSuggestionsInTscExitCode and IgnoreEffectWarningsInTscExitCode +// configuration options. +// +// Import this package with a blank import in the main entry point: +// +// import _ "github.com/effect-ts/tsgo/etsexecutehooks" +package etsexecutehooks diff --git a/etsexecutehooks/init.go b/etsexecutehooks/init.go index a98320ba..f3b663a9 100644 --- a/etsexecutehooks/init.go +++ b/etsexecutehooks/init.go @@ -4,6 +4,7 @@ import ( "github.com/effect-ts/tsgo/etscore" "github.com/effect-ts/tsgo/internal/rule" "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/compiler" "github.com/microsoft/typescript-go/shim/core" "github.com/microsoft/typescript-go/shim/diagnostics" "github.com/microsoft/typescript-go/shim/execute/tsc" @@ -11,6 +12,7 @@ import ( func init() { tsc.RegisterFilterDiagnosticsForExitCodeCallback(filterDiagnosticsForExitCode) + compiler.RegisterFilterDiagnosticsForNoEmitOnErrorCallback(filterDiagnosticsForExitCode) } // filterDiagnosticsForExitCode is the callback registered with the tsc package. diff --git a/etsexecutehooks/init_test.go b/etsexecutehooks/init_test.go index f41a2c70..2d44e877 100644 --- a/etsexecutehooks/init_test.go +++ b/etsexecutehooks/init_test.go @@ -1,14 +1,48 @@ package etsexecutehooks import ( + "context" "testing" "github.com/effect-ts/tsgo/etscore" "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/compiler" "github.com/microsoft/typescript-go/shim/core" "github.com/microsoft/typescript-go/shim/diagnostics" + "github.com/microsoft/typescript-go/shim/tspath" ) +type noEmitProgram struct { + opts *core.CompilerOptions + diagnostics []*ast.Diagnostic +} + +func (p *noEmitProgram) Options() *core.CompilerOptions { return p.opts } +func (p *noEmitProgram) GetSourceFile(string) *ast.SourceFile { return nil } +func (p *noEmitProgram) GetSourceFiles() []*ast.SourceFile { return nil } +func (p *noEmitProgram) GetConfigFileParsingDiagnostics() []*ast.Diagnostic { return p.diagnostics } +func (p *noEmitProgram) GetSyntacticDiagnostics(context.Context, *ast.SourceFile) []*ast.Diagnostic { + return nil +} +func (p *noEmitProgram) GetBindDiagnostics(context.Context, *ast.SourceFile) []*ast.Diagnostic { + return nil +} +func (p *noEmitProgram) GetProgramDiagnostics() []*ast.Diagnostic { return nil } +func (p *noEmitProgram) GetGlobalDiagnostics(context.Context) []*ast.Diagnostic { return nil } +func (p *noEmitProgram) GetSemanticDiagnostics(context.Context, *ast.SourceFile) []*ast.Diagnostic { + return nil +} +func (p *noEmitProgram) GetDeclarationDiagnostics(context.Context, *ast.SourceFile) []*ast.Diagnostic { + return nil +} +func (p *noEmitProgram) GetSuggestionDiagnostics(context.Context, *ast.SourceFile) []*ast.Diagnostic { + return nil +} +func (p *noEmitProgram) Emit(context.Context, compiler.EmitOptions) *compiler.EmitResult { return nil } +func (p *noEmitProgram) CommonSourceDirectory() string { return "" } +func (p *noEmitProgram) IsSourceFileDefaultLibrary(tspath.Path) bool { return false } +func (p *noEmitProgram) Program() *compiler.Program { return nil } + // makeDiag creates a minimal diagnostic with the given code and category. func makeDiag(code int32, category diagnostics.Category) *ast.Diagnostic { return ast.NewDiagnosticFromSerialized( @@ -112,6 +146,29 @@ func TestFilterDiagnosticsForExitCode_IgnoreWarnings(t *testing.T) { } } +func TestNoEmitOnError_IgnoresConfiguredEffectWarnings(t *testing.T) { + t.Parallel() + opts := &core.CompilerOptions{ + NoEmitOnError: core.BoolToTristate(true), + Effect: &etscore.EffectPluginOptions{ + IgnoreEffectWarningsInTscExitCode: true, + }, + } + program := &noEmitProgram{ + opts: opts, + diagnostics: []*ast.Diagnostic{makeDiag(377011, diagnostics.CategoryWarning)}, + } + + if result := compiler.HandleNoEmitOnError(context.Background(), program, nil); result != nil { + t.Fatal("expected ignored Effect warning not to suppress emit") + } + + program.diagnostics = []*ast.Diagnostic{makeDiag(1002, diagnostics.CategoryError)} + if result := compiler.HandleNoEmitOnError(context.Background(), program, nil); result == nil || !result.EmitSkipped { + t.Fatal("expected TypeScript error to suppress emit") + } +} + func TestFilterDiagnosticsForExitCode_ErrorsNotFilteredByDefault(t *testing.T) { t.Parallel() // With suggestion+warning ignore set but error ignore NOT set, errors are kept diff --git a/etslshooks/init.go b/etslshooks/init.go index 121fe951..8314de11 100644 --- a/etslshooks/init.go +++ b/etslshooks/init.go @@ -325,18 +325,6 @@ func formatLayerHover(tp *typeparser.TypeParser, c *checker.Checker, sf *ast.Sou return b.String() } -// formatLayerTypeParams formats Layer type parameters (Provides, Error, Requires). -func formatLayerTypeParams(c *checker.Checker, layer *typeparser.Layer, isMarkdown bool) string { - rOutStr := c.TypeToStringEx(layer.ROut, nil, checker.TypeFormatFlagsNoTruncation, nil) - eStr := c.TypeToStringEx(layer.E, nil, checker.TypeFormatFlagsNoTruncation, nil) - rInStr := c.TypeToStringEx(layer.RIn, nil, checker.TypeFormatFlagsNoTruncation, nil) - - if isMarkdown { - return fmt.Sprintf("```ts\n/* Layer Type Parameters */\ntype Provides = %s\ntype Error = %s\ntype Requires = %s\n```\n", rOutStr, eStr, rInStr) - } - return fmt.Sprintf("Layer Type Parameters:\n Provides = %s\n Error = %s\n Requires = %s\n", rOutStr, eStr, rInStr) -} - // isDeclarationName checks whether the given node is the name node of a variable or property declaration. // This is used to restrict layer hover enrichment to the declaration name only, // not to arbitrary nodes within the initializer expression. diff --git a/etsoxlintrunner/runner.go b/etsoxlintrunner/runner.go index 09633895..9a7dbcde 100644 --- a/etsoxlintrunner/runner.go +++ b/etsoxlintrunner/runner.go @@ -65,7 +65,7 @@ func RunRule( ) ([]*ast.Diagnostic, error) { normalized := normalizeOptions(options, ruleName) - return rulerunner.Run(ctx, program, c, sf, &normalized, []string{ruleName}) + return rulerunner.Run(ctx, program, c, sf, &normalized, []string{ruleName}, rulerunner.MinVisibleSeverity(&normalized)) } func normalizeOptions(options *etscore.EffectPluginOptions, ruleName string) etscore.EffectPluginOptions { @@ -103,7 +103,7 @@ func RunRuleAndReport( return errors.New("diagnostic adapter Report callback is required") } normalized := normalizeOptions(options, ruleName) - diagnostics, err := rulerunner.Run(ctx, program, c, sf, &normalized, []string{ruleName}) + diagnostics, err := rulerunner.Run(ctx, program, c, sf, &normalized, []string{ruleName}, rulerunner.MinVisibleSeverity(&normalized)) if err != nil { return err } diff --git a/flake.lock b/flake.lock index 9e2928b4..bc945402 100644 --- a/flake.lock +++ b/flake.lock @@ -43,17 +43,17 @@ "typescript-go-src": { "flake": false, "locked": { - "lastModified": 1785807459, - "narHash": "sha256-aW/VXTodfPOfAszo3WQ+yyX2H3uPw9Jd6x24hhSKOe0=", + "lastModified": 1786142966, + "narHash": "sha256-bTxCaH2cjDU46/xQTScFgHbEeGBy7Omv87FCj22wVxs=", "owner": "microsoft", "repo": "typescript-go", - "rev": "12318e599d21f516defea3b20e5d44b9369da723", + "rev": "24fabe95acba758c05fcb349bf427a3a0c8ad676", "type": "github" }, "original": { "owner": "microsoft", "repo": "typescript-go", - "rev": "12318e599d21f516defea3b20e5d44b9369da723", + "rev": "24fabe95acba758c05fcb349bf427a3a0c8ad676", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 340d4907..67efc1a1 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ nixpkgsUnstable.url = "github:NixOS/nixpkgs/nixos-unstable"; /* Source of truth: the next profile in `_packages/tsgo/upstream.json`. */ typescript-go-src = { - url = "github:microsoft/typescript-go/12318e599d21f516defea3b20e5d44b9369da723?submodules=1"; + url = "github:microsoft/typescript-go/24fabe95acba758c05fcb349bf427a3a0c8ad676?submodules=1"; flake = false; }; /* Derived from the selected TypeScript-Go revision and recorded in the manifest. */ diff --git a/internal/bundledeffect/effect.go b/internal/bundledeffect/effect.go index 1d948c8f..de8c6e05 100644 --- a/internal/bundledeffect/effect.go +++ b/internal/bundledeffect/effect.go @@ -47,12 +47,6 @@ func EnsurePackageInstalled(version EffectVersion, packageName string) error { return nil } -func PackageFile(version EffectVersion, packageName string, file string) (string, bool) { - path := pathpkg.Join(string(version), "node_modules", packageName, file) - content, ok := fixtures().files[path] - return string(content), ok -} - type fixtureProfileManifest struct { Requested map[string]string `json:"requested"` Resolved map[string]string `json:"resolved"` diff --git a/internal/codefixes/disable_diagnostics.go b/internal/codefixes/disable_diagnostics.go deleted file mode 100644 index 91109639..00000000 --- a/internal/codefixes/disable_diagnostics.go +++ /dev/null @@ -1,44 +0,0 @@ -// Package codefixes provides Effect-specific code fix providers. -package codefixes - -import ( - "github.com/effect-ts/tsgo/internal/rule" - "github.com/effect-ts/tsgo/internal/rules" -) - -// EffectDisableErrorCodes returns all Effect diagnostic codes that support disable actions. -func EffectDisableErrorCodes() []int32 { - return rule.AllCodes(rules.All) -} - -// CodeToRuleName returns the rule name for an Effect diagnostic code. -// Returns "unknown" if the code is not recognized. -func CodeToRuleName(code int32) string { - name := rule.CodeToRuleName(rules.All, code) - if name == "" { - return "unknown" - } - return name -} - -// DisableNextLineComment generates the comment text to disable a rule for the next line. -// Format: // @effect-diagnostics-next-line {ruleName}:off -func DisableNextLineComment(ruleName string) string { - return "// @effect-diagnostics-next-line " + ruleName + ":off\n" -} - -// DisableFileComment generates the comment text to disable a rule for the entire file. -// Format: /** @effect-diagnostics {ruleName}:skip-file */ -func DisableFileComment(ruleName string) string { - return "/** @effect-diagnostics " + ruleName + ":skip-file */\n" -} - -// DisableNextLineDescription generates the action description for "Disable for this line". -func DisableNextLineDescription(ruleName string) string { - return "Disable " + ruleName + " for this line" -} - -// DisableFileDescription generates the action description for "Disable for entire file". -func DisableFileDescription(ruleName string) string { - return "Disable " + ruleName + " for entire file" -} diff --git a/internal/codegen/codegen.go b/internal/codegen/codegen.go index 15a568b7..7bdd90ab 100644 --- a/internal/codegen/codegen.go +++ b/internal/codegen/codegen.go @@ -9,13 +9,3 @@ type Codegen struct { // Description explains what the codegen does. Description string } - -// ByName finds a codegen by name in a slice. Returns nil if not found. -func ByName(codegens []Codegen, name string) *Codegen { - for i := range codegens { - if codegens[i].Name == name { - return &codegens[i] - } - } - return nil -} diff --git a/internal/codegens/codegens.go b/internal/codegens/codegens.go index e362b5a0..fcdca723 100644 --- a/internal/codegens/codegens.go +++ b/internal/codegens/codegens.go @@ -20,8 +20,3 @@ var All = []codegen.Codegen{ Description: "Generate Schemas from types", }, } - -// ByName finds a codegen by name. Returns nil if not found. -func ByName(name string) *codegen.Codegen { - return codegen.ByName(All, name) -} diff --git a/internal/completions/completions.go b/internal/completions/completions.go index 23e58086..8fde3010 100644 --- a/internal/completions/completions.go +++ b/internal/completions/completions.go @@ -21,13 +21,3 @@ var All = []completion.Completion{ rpcMakeClasses, schemaBrand, } - -// ByName finds a completion by its unique name. -func ByName(name string) *completion.Completion { - for i := range All { - if All[i].Name == name { - return &All[i] - } - } - return nil -} diff --git a/internal/directives/parser.go b/internal/directives/parser.go index 71d59430..bf5f5d91 100644 --- a/internal/directives/parser.go +++ b/internal/directives/parser.go @@ -320,6 +320,44 @@ func (ds *DirectiveSet) HasEnablingDirective(ruleName string) bool { return false } +// HasAnyDirectiveForRule returns true if any directive in the file references +// the given rule, either by name or via a wildcard, regardless of the severity +// it assigns. This includes file-level, section, and next-line directives. It +// is used to decide whether a rule below the minimum visible severity can be +// safely skipped pre-execution: when a directive mentions the rule, running it +// may be required either to honor a severity override or to mark the directive +// as used for unusedDirective tracking. +func (ds *DirectiveSet) HasAnyDirectiveForRule(ruleName string) bool { + ruleLower := strings.ToLower(ruleName) + + matches := func(rules []RuleSeverity) bool { + for _, rs := range rules { + ruleNameLower := strings.ToLower(rs.Rule) + if ruleNameLower == ruleLower || ruleNameLower == "*" { + return true + } + } + return false + } + + if matches(ds.fileLevel) { + return true + } + for _, sd := range ds.sectionDirectives { + if matches(sd.Rules) { + return true + } + } + for _, directives := range ds.byLine { + for _, d := range directives { + if matches(d.Rules) { + return true + } + } + } + return false +} + // GetUnusedNextLineDirectives returns next-line directives that did not suppress any diagnostic. // This is used to report unusedDirective warnings. func (ds *DirectiveSet) GetUnusedNextLineDirectives(allDirectives []Directive) []Directive { diff --git a/internal/directives/parser_test.go b/internal/directives/parser_test.go index c943b80d..29de6f22 100644 --- a/internal/directives/parser_test.go +++ b/internal/directives/parser_test.go @@ -924,3 +924,73 @@ Effect.succeed(1)` t.Errorf("HasEnablingDirective(\"floatingEffect\") = %v, want false (wildcard off should not enable)", result) } } + +func TestHasAnyDirectiveForRule(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + ruleName string + expected bool + }{ + { + name: "no directives", + source: "Effect.succeed(1)", + ruleName: "floatingEffect", + expected: false, + }, + { + name: "section directive mentions rule by name", + source: "// @effect-diagnostics floatingEffect:error\nEffect.succeed(1)", + ruleName: "floatingEffect", + expected: true, + }, + { + name: "section directive mentions other rule only", + source: "// @effect-diagnostics pocRule:error\nEffect.succeed(1)", + ruleName: "floatingEffect", + expected: false, + }, + { + name: "wildcard section directive mentions every rule", + source: "// @effect-diagnostics *:error\nEffect.succeed(1)", + ruleName: "floatingEffect", + expected: true, + }, + { + name: "lowering directive still counts as a mention", + source: "// @effect-diagnostics floatingEffect:off\nEffect.succeed(1)", + ruleName: "floatingEffect", + expected: true, + }, + { + name: "next-line directive counts as a mention", + source: "// @effect-diagnostics-next-line floatingEffect:off\nEffect.log(\"x\")", + ruleName: "floatingEffect", + expected: true, + }, + { + name: "skip-file directive counts as a mention", + source: "// @effect-diagnostics floatingEffect:skip-file", + ruleName: "floatingEffect", + expected: true, + }, + { + name: "rule name matching is case-insensitive", + source: "// @effect-diagnostics FLOATINGeffect:error\nEffect.succeed(1)", + ruleName: "floatingEffect", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ds := BuildDirectiveSet(CollectEffectDirectives(tt.source)) + if got := ds.HasAnyDirectiveForRule(tt.ruleName); got != tt.expected { + t.Errorf("HasAnyDirectiveForRule(%q) = %v, want %v", tt.ruleName, got, tt.expected) + } + }) + } +} diff --git a/internal/effectconfigraw/hooks.go b/internal/effectconfigraw/hooks.go index bda14b04..57105926 100644 --- a/internal/effectconfigraw/hooks.go +++ b/internal/effectconfigraw/hooks.go @@ -199,25 +199,6 @@ func findEffectPluginRaw(plugins []any) (*collections.OrderedMap[string, any], i return nil, -1 } -func cloneRawJSON(value any) any { - switch value := value.(type) { - case *collections.OrderedMap[string, any]: - cloned := new(collections.OrderedMap[string, any]) - for key, entryValue := range value.Entries() { - cloned.Set(key, cloneRawJSON(entryValue)) - } - return cloned - case []any: - cloned := make([]any, len(value)) - for i, entryValue := range value { - cloned[i] = cloneRawJSON(entryValue) - } - return cloned - default: - return value - } -} - func cloneEffectOptions(source *etscore.EffectPluginOptions) *etscore.EffectPluginOptions { if source == nil { return nil diff --git a/internal/effecttest/effect_in_failure_ts2589_test.go b/internal/effecttest/effect_in_failure_ts2589_test.go index 3b360998..56949ce9 100644 --- a/internal/effecttest/effect_in_failure_ts2589_test.go +++ b/internal/effecttest/effect_in_failure_ts2589_test.go @@ -40,6 +40,45 @@ func TestEffectInFailureCanTriggerPluginOnlyTS2589(t *testing.T) { } } +func TestTaggedTemplateSymbolInterpolationDoesNotReportTS2731(t *testing.T) { + t.Parallel() + + for _, rule := range []string{"anyUnknownInErrorContext", "effectInFailure"} { + t.Run(rule, func(t *testing.T) { + t.Parallel() + + diagnostics := collectDiagnosticStringsFromContent(t, buildTaggedTemplateSymbolCase(rule)) + if hasDiagnosticCode(diagnostics, "TS2731:") { + t.Fatalf("did not expect TS2731 with %s enabled, got %v", rule, diagnostics) + } + }) + } +} + +func buildTaggedTemplateSymbolCase(rule string) string { + return `// @filename: tsconfig.json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "` + rule + `": "error" + } + } + ] + } +} + +// @filename: test.ts +const self: unique symbol = Symbol("self") +function sql(strings: TemplateStringsArray, ...args: Array): string { + return strings.join("?") + args.length +} +export const q = sql` + "`(${self}) < 1`" + ` +` +} + func TestIssue362RemedaChunkDoesNotReportExcessiveTypeDepth(t *testing.T) { t.Parallel() diff --git a/internal/rewriter/tracker.go b/internal/rewriter/tracker.go index 18f406e8..04da78f5 100644 --- a/internal/rewriter/tracker.go +++ b/internal/rewriter/tracker.go @@ -39,10 +39,6 @@ func NewTracker(raw *change.Tracker) *Tracker { return &Tracker{Tracker: raw} } -func (t *Tracker) Raw() *change.Tracker { - return t.Tracker -} - func (t *Tracker) GetChanges() map[string][]*lsproto.TextEdit { t.flushPendingBefore() return t.Tracker.GetChanges() @@ -69,39 +65,6 @@ func (t *Tracker) ReplaceNode(sourceFile *ast.SourceFile, oldNode *ast.Node, new t.ReplaceRangeWithText(sourceFile, rng, text) } -func (t *Tracker) ReplaceNodeWithNodes(sourceFile *ast.SourceFile, oldNode *ast.Node, newNodes []*ast.Node, options *change.NodeOptions) { - if len(newNodes) == 0 || oldNode == nil { - return - } - if len(newNodes) == 1 { - newNode := newNodes[0] - t.ReplaceNode(sourceFile, oldNode, newNode, options) - return - } - if options == nil { - options = &change.NodeOptions{ - LeadingTriviaOption: change.LeadingTriviaOptionExclude, - TrailingTriviaOption: change.TrailingTriviaOptionExclude, - } - } - parts := []string{t.consumePendingPrefix(sourceFile, oldNode)} - for _, node := range newNodes { - if node == nil { - continue - } - parts = append(parts, t.nodeText(sourceFile, node)) - } - text := strings.Join(parts, "\n") - if options.Prefix != "" { - text = options.Prefix + text - } - if options.Suffix != "" { - text += options.Suffix - } - rng := t.GetAdjustedRange(sourceFile, oldNode, oldNode, options.LeadingTriviaOption, options.TrailingTriviaOption) - t.ReplaceRangeWithText(sourceFile, rng, text) -} - func (t *Tracker) InsertNodeBefore(sourceFile *ast.SourceFile, before *ast.Node, newNode *ast.Node, blankLineBetween bool, leadingTriviaOption change.LeadingTriviaOption) { if before == nil || newNode == nil { return diff --git a/internal/rulerunner/diagnostics.go b/internal/rulerunner/diagnostics.go index 6e8950cb..4bf8e591 100644 --- a/internal/rulerunner/diagnostics.go +++ b/internal/rulerunner/diagnostics.go @@ -25,8 +25,22 @@ type RuleDiagnostic struct { Diagnostic *ast.Diagnostic } +// MinVisibleSeverity returns the least visible severity that can surface in +// the current execution mode: in CLI (tsc) mode without includeSuggestionsInTsc +// only warnings and errors are printed, so SeverityWarning; everywhere else +// (LSP, tests) every severity surfaces, so SeverityMessage. +func MinVisibleSeverity(effectConfig *etscore.EffectPluginOptions) etscore.Severity { + if etscore.IsCommandLineMode() && !effectConfig.GetIncludeSuggestionsInTsc() { + return etscore.SeverityWarning + } + return etscore.SeverityMessage +} + // Run executes Effect diagnostics for a source file and returns diagnostics without emitting them. -func Run(ctx context.Context, program checker.Program, c *checker.Checker, sf *ast.SourceFile, effectConfig *etscore.EffectPluginOptions, ruleNames []string) ([]*ast.Diagnostic, error) { +// minSeverity is the least visible severity the caller can surface (see +// MinVisibleSeverity); rules whose configured severity is below it are skipped +// pre-execution unless a directive in the file references them. +func Run(ctx context.Context, program checker.Program, c *checker.Checker, sf *ast.SourceFile, effectConfig *etscore.EffectPluginOptions, ruleNames []string, minSeverity etscore.Severity) ([]*ast.Diagnostic, error) { if sf.IsDeclarationFile || program.IsSourceFileFromExternalLibrary(sf) { return nil, nil } @@ -70,8 +84,8 @@ func Run(ctx context.Context, program checker.Program, c *checker.Checker, sf *a return nil, nil } - allDiagnostics := collectDiagnostics(ctx, program, c, tp, sf, effectConfig, effectiveConfig, resolvedSeverity, directiveSet, selectedRules) - finalDiagnostics := transformDiagnostics(allDiagnostics, sf, directiveSet, effectConfig, resolvedSeverity) + allDiagnostics := collectDiagnostics(ctx, program, c, tp, sf, effectConfig, effectiveConfig, resolvedSeverity, directiveSet, selectedRules, minSeverity) + finalDiagnostics := transformDiagnostics(allDiagnostics, sf, directiveSet, resolvedSeverity, minSeverity) finalDiagnostics = append(finalDiagnostics, unusedDirectiveDiagnostics(sf, effectDirectives, directiveSet, resolvedSeverity)...) return finalDiagnostics, nil @@ -113,6 +127,7 @@ func collectDiagnostics( resolvedSeverity map[string]etscore.Severity, directiveSet *directives.DirectiveSet, selectedRules []*rule.Rule, + minSeverity etscore.Severity, ) []*RuleDiagnostic { var results []*RuleDiagnostic @@ -124,6 +139,15 @@ func collectDiagnostics( if !globalConfig.SkipDisabledOptimization && configSeverity.IsOff() && !directiveSet.HasEnablingDirective(r.Name) { continue } + // A rule below the minimum visible severity can only surface if a + // directive raises it, and it must still run when any directive + // references it so the directive is marked used for unusedDirective + // tracking. + if !globalConfig.SkipDisabledOptimization && + !configSeverity.AtLeastAsVisibleAs(minSeverity) && + !directiveSet.HasAnyDirectiveForRule(r.Name) { + continue + } if directiveSet.IsSkipFile(r.Name) { continue @@ -148,8 +172,8 @@ func transformDiagnostics( diags []*RuleDiagnostic, sf *ast.SourceFile, directiveSet *directives.DirectiveSet, - globalConfig *etscore.EffectPluginOptions, resolvedSeverity map[string]etscore.Severity, + minSeverity etscore.Severity, ) []*ast.Diagnostic { var results []*ast.Diagnostic lineMap := sf.ECMALineMap() @@ -171,16 +195,13 @@ func transformDiagnostics( if effectiveSeverity.IsOff() { continue } + if !effectiveSeverity.AtLeastAsVisibleAs(minSeverity) { + continue + } originalCategory := rd.Diagnostic.Category() newCategory := directives.ToCategory(effectiveSeverity) - if etscore.IsCommandLineMode() && !globalConfig.GetIncludeSuggestionsInTsc() { - if newCategory == tsdiag.CategorySuggestion || newCategory == tsdiag.CategoryMessage { - continue - } - } - if originalCategory != newCategory { results = append(results, createTransformedDiagnostic(rd.Diagnostic, newCategory)) } else { diff --git a/internal/rulerunner/diagnostics_test.go b/internal/rulerunner/diagnostics_test.go new file mode 100644 index 00000000..bdff8e95 --- /dev/null +++ b/internal/rulerunner/diagnostics_test.go @@ -0,0 +1,43 @@ +package rulerunner + +import ( + "testing" + + "github.com/effect-ts/tsgo/etscore" +) + +// Not parallel: EnterCommandLineMode toggles process-global state. +func TestMinVisibleSeverity(t *testing.T) { //nolint:paralleltest + includeSuggestions := &etscore.EffectPluginOptions{IncludeSuggestionsInTsc: true} + dropSuggestions := &etscore.EffectPluginOptions{IncludeSuggestionsInTsc: false} + + t.Run("outside CLI mode every severity is visible", func(t *testing.T) { + if got := MinVisibleSeverity(dropSuggestions); got != etscore.SeverityMessage { + t.Errorf("MinVisibleSeverity = %v, want %v", got, etscore.SeverityMessage) + } + }) + + t.Run("CLI mode without includeSuggestionsInTsc only surfaces warnings and errors", func(t *testing.T) { + restore := etscore.EnterCommandLineMode() + defer restore() + if got := MinVisibleSeverity(dropSuggestions); got != etscore.SeverityWarning { + t.Errorf("MinVisibleSeverity = %v, want %v", got, etscore.SeverityWarning) + } + }) + + t.Run("CLI mode with includeSuggestionsInTsc keeps every severity", func(t *testing.T) { + restore := etscore.EnterCommandLineMode() + defer restore() + if got := MinVisibleSeverity(includeSuggestions); got != etscore.SeverityMessage { + t.Errorf("MinVisibleSeverity = %v, want %v", got, etscore.SeverityMessage) + } + }) + + t.Run("CLI mode with nil config keeps every severity", func(t *testing.T) { + restore := etscore.EnterCommandLineMode() + defer restore() + if got := MinVisibleSeverity(nil); got != etscore.SeverityMessage { + t.Errorf("MinVisibleSeverity = %v, want %v", got, etscore.SeverityMessage) + } + }) +} diff --git a/internal/rules/effect_in_failure.go b/internal/rules/effect_in_failure.go index 7a268e88..534589f7 100644 --- a/internal/rules/effect_in_failure.go +++ b/internal/rules/effect_in_failure.go @@ -60,6 +60,14 @@ var EffectInFailure = rule.Rule{ continue } + // Declared-type prefilter: skip the expensive flow-analysis query + // for reference nodes that conclusively cannot have a strict + // Effect flow type. Skipped nodes can never match, so no + // shouldSkip bookkeeping is needed. + if !ctx.TypeParser.NodeCouldBeStrictEffect(node) { + continue + } + nodeType := ctx.TypeParser.GetTypeAtLocation(node) if nodeType == nil { continue diff --git a/internal/rules/prefer_typed_schema_decoder.go b/internal/rules/prefer_typed_schema_decoder.go index 697a666a..a755f305 100644 --- a/internal/rules/prefer_typed_schema_decoder.go +++ b/internal/rules/prefer_typed_schema_decoder.go @@ -146,8 +146,10 @@ func analyzeTypedSchemaDecoderApplication(tp *typeparser.TypeParser, c *checker. } assignableType := inputType - if literal := ast.SkipParentheses(inputNode); literal != nil && (literal.Kind == ast.KindObjectLiteralExpression || literal.Kind == ast.KindArrayLiteralExpression) { - assignableType = checker.Checker_checkExpressionWithContextualType(c, literal, schemaType.E, nil, checker.CheckModeTypeOnly) + if inputNode != nil { + if literal := ast.SkipParentheses(inputNode); literal != nil && (literal.Kind == ast.KindObjectLiteralExpression || literal.Kind == ast.KindArrayLiteralExpression) { + assignableType = checker.Checker_checkExpressionWithContextualType(c, literal, schemaType.E, nil, checker.CheckModeTypeOnly) + } } if assignableType == nil || !checker.Checker_isTypeAssignableTo(c, assignableType, schemaType.E) { return nil diff --git a/internal/rules/promise_in_effect_success.go b/internal/rules/promise_in_effect_success.go index 63da0f37..6b14105f 100644 --- a/internal/rules/promise_in_effect_success.go +++ b/internal/rules/promise_in_effect_success.go @@ -61,12 +61,25 @@ var PromiseInEffectSuccess = rule.Rule{ continue } - t := ctx.TypeParser.GetTypeAtLocation(node) + // Declared-type prefilter: a diagnostic requires a strict Effect + // flow type on the node, which reference nodes with a + // conclusively non-Effect declared type — and calls whose + // resolved signature conclusively cannot return one — can never + // have. Skipped nodes can never match, so no matched-map + // bookkeeping is needed. + if !ctx.TypeParser.NodeCouldBeStrictEffect(node) { + continue + } + + var t *checker.Type if node.Kind == ast.KindCallExpression { if signature := ctx.Checker.GetResolvedSignature(node); signature != nil { t = ctx.Checker.GetReturnTypeOfSignature(signature) } } + if t == nil { + t = ctx.TypeParser.GetTypeAtLocation(node) + } effect := ctx.TypeParser.StrictEffectType(t, node) if effect == nil || !typeContainsPromise(ctx.TypeParser, effect.A) { continue diff --git a/internal/rules/rules_json_test.go b/internal/rules/rules_json_test.go index 53d8fe60..43a02524 100644 --- a/internal/rules/rules_json_test.go +++ b/internal/rules/rules_json_test.go @@ -89,6 +89,27 @@ func TestReadmeTable(t *testing.T) { } } +func TestReadmeLinksAreAbsolute(t *testing.T) { + t.Parallel() + readmePath := filepath.Join(repoRoot(t), "README.md") + content, err := os.ReadFile(readmePath) + if err != nil { + t.Fatalf("read README.md: %v", err) + } + + patterns := []*regexp.Regexp{ + regexp.MustCompile(`href="([^"]+)"`), + regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`), + } + for _, pattern := range patterns { + for _, match := range pattern.FindAllStringSubmatch(string(content), -1) { + if !strings.HasPrefix(match[1], "https://") && !strings.HasPrefix(match[1], "http://") { + t.Errorf("README.md contains non-absolute link %q", match[1]) + } + } + } +} + func TestMetadataJSON(t *testing.T) { t.Parallel() root := repoRoot(t) @@ -737,7 +758,7 @@ func generateReadmeTable() string { lines = append(lines, fmt.Sprintf(" %s %s", html.EscapeString(group.Name), html.EscapeString(group.Description))) for _, r := range groupRules { - lines = append(lines, fmt.Sprintf(" %s%s", + lines = append(lines, fmt.Sprintf(" %s%s", kebabCase(r.name), html.EscapeString(r.name), html.EscapeString(r.description))) } } diff --git a/internal/schemagen/schemagen.go b/internal/schemagen/schemagen.go index b35b36b4..890e0938 100644 --- a/internal/schemagen/schemagen.go +++ b/internal/schemagen/schemagen.go @@ -8,9 +8,9 @@ import ( "errors" "fmt" + "github.com/effect-ts/tsgo/internal/rewriter" "github.com/effect-ts/tsgo/internal/typeparser" "github.com/microsoft/typescript-go/shim/ast" - "github.com/effect-ts/tsgo/internal/rewriter" "github.com/microsoft/typescript-go/shim/scanner" ) @@ -679,16 +679,6 @@ func (g *SchemaGen) createExportSchemaClassDeclaration(name string, properties [ ) } -// ProcessNode is the public entry point for processing a single type node. -// On error, returns a comment node describing the error. -func (g *SchemaGen) ProcessNode(node *ast.Node) *ast.Node { - result, err := g.processNode(node) - if err != nil { - return g.Tracker.NewIdentifier(fmt.Sprintf("undefined /* %s */", err.Error())) - } - return result -} - // Process converts an interface or type alias declaration into a schema statement. // On error, returns a comment node describing the error. func (g *SchemaGen) Process(node *ast.Node, preferClass bool) *ast.Node { diff --git a/internal/schemagen/structural.go b/internal/schemagen/structural.go index 7b947bc1..75a1c8c2 100644 --- a/internal/schemagen/structural.go +++ b/internal/schemagen/structural.go @@ -11,10 +11,10 @@ import ( "maps" "regexp" + "github.com/effect-ts/tsgo/internal/rewriter" "github.com/effect-ts/tsgo/internal/typeparser" "github.com/microsoft/typescript-go/shim/ast" "github.com/microsoft/typescript-go/shim/checker" - "github.com/effect-ts/tsgo/internal/rewriter" ) // StructuralSchemaGen holds the context for converting resolved types to Schema expressions. @@ -696,8 +696,3 @@ func (g *StructuralSchemaGen) Process(typeMap map[string]*checker.Type, scope *a return g.schemaStatements } - -// Statements returns the accumulated schema statements. -func (g *StructuralSchemaGen) Statements() []*ast.Node { - return g.schemaStatements -} diff --git a/internal/typeparser/could_be_strict_effect.go b/internal/typeparser/could_be_strict_effect.go new file mode 100644 index 00000000..9f6b22b0 --- /dev/null +++ b/internal/typeparser/could_be_strict_effect.go @@ -0,0 +1,137 @@ +package typeparser + +import ( + "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/checker" +) + +// strictEffectTypeNames are the type symbol names that StrictEffectType can +// match. couldBeNamed takes a set so future prefilters for other wrapper +// types (Stream, Layer, ...) can reuse the same conservative walk. +var strictEffectTypeNames = map[string]bool{"Effect": true} + +// NodeCouldBeStrictEffect reports whether node's flow type could possibly be +// a strict Effect type (a type whose symbol is named "Effect", see +// StrictEffectType). For reference nodes (identifiers and property accesses) +// it inspects the referenced symbol's declared type, which is cheap compared +// to the flow analysis performed by GetTypeAtLocation. Flow narrowing can +// only refine the declared type — select union constituents, narrow +// any/unknown, or intersect it — so a declared type that conclusively +// contains no possibly-Effect constituent can never produce a strict-Effect +// flow type. +// +// It returns true ("cannot rule out") for every other node kind and whenever +// the answer is not conclusively negative, so whole-file walker rules may use +// a false result to skip expensive GetTypeAtLocation queries without ever +// missing a strict Effect type. +func (tp *TypeParser) NodeCouldBeStrictEffect(node *ast.Node) bool { + if tp == nil || tp.checker == nil || node == nil { + return true + } + if node.Kind == ast.KindCallExpression { + return tp.callCouldReturnStrictEffect(node) + } + if node.Kind != ast.KindIdentifier && node.Kind != ast.KindPropertyAccessExpression { + return true + } + sym := tp.ReferenceSymbolAtNode(node) + if sym == nil { + return true + } + return tp.SymbolCouldBeStrictEffect(sym) +} + +// callCouldReturnStrictEffect reports whether a call expression's type could +// possibly be a strict Effect type, based on the return type of its resolved +// signature. The signature and its return type are cached from the main check +// phase, so consulting them is cheap compared to re-checking the call via +// GetTypeAtLocation. A call expression's type is its resolved signature's +// return type (union-widened with undefined for optional chains, which the +// conservative union walk handles), so a conclusively non-Effect declared +// return type rules the node out. +func (tp *TypeParser) callCouldReturnStrictEffect(node *ast.Node) (result bool) { + defer func() { + if r := recover(); r != nil { + result = true + } + }() + signature := tp.checker.GetResolvedSignature(node) + if signature == nil { + return true + } + return couldBeNamed(tp.checker.GetReturnTypeOfSignature(signature), strictEffectTypeNames, 0) +} + +// SymbolCouldBeStrictEffect reports whether a reference to sym could possibly +// have a strict-Effect flow type, based on the symbol's declared type only. +func (tp *TypeParser) SymbolCouldBeStrictEffect(sym *ast.Symbol) bool { + if tp == nil || tp.checker == nil || sym == nil { + return true + } + declared := tp.getTypeOfSymbolSafe(sym) + return tp.CouldBeStrictEffect(declared) +} + +// getTypeOfSymbolSafe wraps Checker.GetTypeOfSymbol with a panic guard, +// returning nil (treated as inconclusive by callers) on any checker panic. +func (tp *TypeParser) getTypeOfSymbolSafe(sym *ast.Symbol) (result *checker.Type) { + defer func() { + if r := recover(); r != nil { + result = nil + } + }() + return tp.checker.GetTypeOfSymbol(sym) +} + +// CouldBeStrictEffect reports whether flow narrowing starting from declared +// type t could ever produce a strict Effect type. It is deliberately +// conservative: it only returns false when t is conclusively non-Effect (a +// primitive/never type, or a plain object type with a non-nil symbol whose +// name is not "Effect"). Any/unknown, type variables, unions, intersections +// and symbol-less types all return true. +func (tp *TypeParser) CouldBeStrictEffect(t *checker.Type) bool { + return couldBeNamed(t, strictEffectTypeNames, 0) +} + +// couldBeNamed reports whether flow narrowing starting from declared type t +// could ever produce a type whose symbol name is in names. False only on a +// conclusive negative. +func couldBeNamed(t *checker.Type, names map[string]bool, depth int) bool { + if t == nil { + return true + } + flags := t.Flags() + if flags&checker.TypeFlagsAnyOrUnknown != 0 { + return true + } + if flags&checker.TypeFlagsUnionOrIntersection != 0 { + if depth > 4 { + return true + } + for _, member := range t.Types() { + if couldBeNamed(member, names, depth+1) { + return true + } + } + return false + } + // Type parameters, indexed accesses, conditionals, substitutions, etc. + // can instantiate to anything. + if flags&checker.TypeFlagsInstantiable != 0 { + return true + } + // Primitives and never can never narrow to an object type. + if flags&(checker.TypeFlagsPrimitive|checker.TypeFlagsNever) != 0 { + return false + } + // Anything that is not a plain object type at this point is unexpected; + // stay conservative. + if flags&checker.TypeFlagsObject == 0 { + return true + } + sym := t.Symbol() + if sym == nil { + return true + } + return names[sym.Name] +} diff --git a/internal/typeparser/could_be_strict_effect_test.go b/internal/typeparser/could_be_strict_effect_test.go new file mode 100644 index 00000000..9a140bca --- /dev/null +++ b/internal/typeparser/could_be_strict_effect_test.go @@ -0,0 +1,242 @@ +package typeparser + +import ( + "strings" + "testing" + + "github.com/effect-ts/tsgo/internal/bundledeffect" + "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/scanner" +) + +// findIdentifierByName returns the first identifier node with the given text +// that is not a declaration name. +func findIdentifierByName(t *testing.T, sf *ast.SourceFile, name string) *ast.Node { + t.Helper() + var found *ast.Node + var visit func(node *ast.Node) bool + visit = func(node *ast.Node) bool { + if found != nil { + return true + } + if node.Kind == ast.KindIdentifier && node.Text() == name && !ast.IsDeclarationName(node) { + found = node + return true + } + node.ForEachChild(visit) + return false + } + sf.AsNode().ForEachChild(visit) + if found == nil { + t.Fatalf("identifier %q not found in source", name) + } + return found +} + +func TestNodeCouldBeStrictEffect(t *testing.T) { + t.Parallel() + if err := bundledeffect.EnsurePackageInstalled(bundledeffect.EffectV4, "effect"); err != nil { + t.Skip("Effect v4 not installed:", err) + } + + source := ` +import { Effect } from "effect" + +const anEffect = Effect.succeed(1) +const aString = "hello" +const aNumber = 42 +const anAny: any = null +const anUnknown: unknown = null +const aUnionWithEffect: Effect.Effect | string = anEffect +const aUnionWithoutEffect: string | number | boolean = "x" +interface Plain { readonly value: number } +const aPlainObject: Plain = { value: 1 } +const anObjectKeyword: object = { value: 1 } +function generic(param: T): T { return param } + +export const uses = [ + anEffect, + aString, + aNumber, + anAny, + anUnknown, + aUnionWithEffect, + aUnionWithoutEffect, + aPlainObject, + anObjectKeyword, +] +export function inner(param: T) { return [param] } +` + _, tp, sf, done := compileAndGetCheckerAndSourceFileWithEffectV4Internal(t, source) + defer done() + + tests := []struct { + identifier string + expected bool + }{ + // Conclusive negatives: the declared type can never flow-narrow into + // a type whose symbol is named "Effect". + {"aString", false}, + {"aNumber", false}, + {"aUnionWithoutEffect", false}, + {"aPlainObject", false}, + // Effect references and everything inconclusive must stay true. + {"anEffect", true}, + {"anAny", true}, + {"anUnknown", true}, + {"aUnionWithEffect", true}, + {"anObjectKeyword", true}, // the object keyword type has no symbol + {"param", true}, // type parameters can instantiate to anything + } + + // Subtests deliberately avoided: all cases share one checker, which is + // not safe for the parallel subtests the tparallel linter would require. + for _, tt := range tests { + node := findIdentifierByName(t, sf, tt.identifier) + if got := tp.NodeCouldBeStrictEffect(node); got != tt.expected { + t.Errorf("NodeCouldBeStrictEffect(%s) = %v, want %v", tt.identifier, got, tt.expected) + } + } + + // Other non-reference node kinds (e.g. binary expressions) are never + // ruled out. + var binary *ast.Node + var visit func(node *ast.Node) bool + visit = func(node *ast.Node) bool { + if binary != nil { + return true + } + if node.Kind == ast.KindArrayLiteralExpression { + binary = node + return true + } + node.ForEachChild(visit) + return false + } + sf.AsNode().ForEachChild(visit) + if binary == nil { + t.Fatal("no array literal expression found") + } + if !tp.NodeCouldBeStrictEffect(binary) { + t.Error("non-reference, non-call node kinds must not be ruled out") + } + + // Nil receiver and nil node stay conservative. + var nilTp *TypeParser + if !nilTp.NodeCouldBeStrictEffect(nil) { + t.Error("nil TypeParser must not rule anything out") + } + if !tp.NodeCouldBeStrictEffect(nil) { + t.Error("nil node must not be ruled out") + } +} + +func TestCouldBeStrictEffectDeepUnionStaysConservative(t *testing.T) { + t.Parallel() + if err := bundledeffect.EnsurePackageInstalled(bundledeffect.EffectV4, "effect"); err != nil { + t.Skip("Effect v4 not installed:", err) + } + + // A union nested beyond the recursion depth limit must return true even + // though every member is a primitive literal. + members := make([]string, 0, 40) + for _, s := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { + members = append(members, `"lit`+s+`"`) + } + source := ` +type Deep = ` + strings.Join(members, " | ") + ` +const deepValue: Deep = "lita" +export const use = [deepValue] +` + _, tp, sf, done := compileAndGetCheckerAndSourceFileWithEffectV4Internal(t, source) + defer done() + + node := findIdentifierByName(t, sf, "deepValue") + // Literal unions are flat, so this exercises the union walk; whatever the + // nesting, the answer may be false only when provably safe — a flat + // primitive union is provably safe. + if tp.NodeCouldBeStrictEffect(node) { + t.Error("flat primitive literal union should be conclusively non-Effect") + } +} + +// findCallByCalleeName returns the first call expression whose callee text +// contains the given substring. +func findCallByCalleeName(t *testing.T, sf *ast.SourceFile, callee string) *ast.Node { + t.Helper() + var found *ast.Node + var visit func(node *ast.Node) bool + visit = func(node *ast.Node) bool { + if found != nil { + return true + } + if node.Kind == ast.KindCallExpression { + expr := node.AsCallExpression().Expression + if expr != nil && strings.Contains(scanner.GetTextOfNode(expr), callee) { + found = node + return true + } + } + node.ForEachChild(visit) + return false + } + sf.AsNode().ForEachChild(visit) + if found == nil { + t.Fatalf("call to %q not found in source", callee) + } + return found +} + +func TestCallCouldReturnStrictEffect(t *testing.T) { + t.Parallel() + if err := bundledeffect.EnsurePackageInstalled(bundledeffect.EffectV4, "effect"); err != nil { + t.Skip("Effect v4 not installed:", err) + } + + source := ` +import { Effect } from "effect" + +declare function makesEffect(): Effect.Effect +declare function makesString(): string +declare function makesUnion(flag: boolean): Effect.Effect | undefined +declare function makesAny(): any +declare function generic(value: T): T +declare const maybe: { makesEffect(): Effect.Effect } | undefined + +export const uses = [ + makesEffect(), + makesString(), + makesUnion(true), + makesAny(), + generic("x"), + maybe?.makesEffect(), +] +` + _, tp, sf, done := compileAndGetCheckerAndSourceFileWithEffectV4Internal(t, source) + defer done() + + // Subtests deliberately avoided: all cases share one checker, which is + // not safe for the parallel subtests the tparallel linter would require. + tests := []struct { + callee string + expected bool + }{ + // Conclusive negative: the declared return type can never be Effect. + {"makesString", false}, + // Effect-returning calls and every inconclusive case stay true. + {"makesEffect", true}, + {"makesUnion", true}, // union containing Effect + {"makesAny", true}, // any return + // The resolved signature is instantiated, so generic("x") conclusively + // returns the primitive literal "x" and is ruled out. + {"generic", false}, + {"maybe?.makesEffect", true}, // optional chain: Effect | undefined union + } + + for _, tt := range tests { + call := findCallByCalleeName(t, sf, tt.callee) + if got := tp.NodeCouldBeStrictEffect(call); got != tt.expected { + t.Errorf("NodeCouldBeStrictEffect(call %s) = %v, want %v", tt.callee, got, tt.expected) + } + } +} diff --git a/internal/typeparser/execution_flow.go b/internal/typeparser/execution_flow.go index 13bb69b9..ab01f9a9 100644 --- a/internal/typeparser/execution_flow.go +++ b/internal/typeparser/execution_flow.go @@ -178,7 +178,7 @@ func (ec *executionCollector) visitEachChildWithUsageTarget(node *ast.Node, targ func (ec *executionCollector) visitExpressionNode(node *ast.Expression, parentExpression *GraphSlice) *GraphSlice { rootExpr := parentExpression - if parentExpression == nil && ast.IsExpressionNode(node) { + if parentExpression == nil && ast.IsExpressionNode(node) && !isInsideTypeOnlyHeritageExpression(node) { rootExpr = ec.buildValueNode(node) } ec.visitEachChildWithUsageTarget(node, rootExpr) diff --git a/internal/typeparser/get_type_at_location.go b/internal/typeparser/get_type_at_location.go index 87c03577..65ae32ab 100644 --- a/internal/typeparser/get_type_at_location.go +++ b/internal/typeparser/get_type_at_location.go @@ -49,6 +49,14 @@ func (tp TypeParser) getTypeAtLocationUncached(node *ast.Node) (result *checker. return nil } + // Tagged templates pass interpolation values directly to the tag function; + // they do not stringify them. Asking the checker for the type of the inner + // TemplateExpression forces a normally unreachable checking path that can + // emit TS2731 for symbol-typed interpolations as a side effect. + if node.Kind == ast.KindTemplateExpression && node.Parent != nil && ast.IsTaggedTemplateExpression(node.Parent) { + return nil + } + // A meta property used as a call callee (import.defer(...)) has no type of // its own and the checker debug-asserts when asked (checkMetaProperty); the // enclosing call expression carries the meaningful type. diff --git a/internal/typeparser/schema_type.go b/internal/typeparser/schema_type.go index 58515073..17ec60ad 100644 --- a/internal/typeparser/schema_type.go +++ b/internal/typeparser/schema_type.go @@ -14,20 +14,6 @@ var effectSchemaParserModuleDescriptor = newPackageSourceFileDescriptor("effect" // SchemaTypeId is the property key for Schema's variance struct. const SchemaTypeId = "~effect/Schema/Schema" -// parseSchemaVarianceStruct checks if a type is a Schema variance struct (has _A, _I, _R). -func (tp *TypeParser) parseSchemaVarianceStruct(t *checker.Type) bool { - a := tp.extractInvariantType(t, "_A") - if a == nil { - return false - } - i := tp.extractInvariantType(t, "_I") - if i == nil { - return false - } - r := tp.extractCovariantType(t, "_R") - return r != nil -} - // IsSchemaType returns true if the type is a Schema type (v4 or v3). func (tp *TypeParser) IsSchemaType(t *checker.Type, atLocation *ast.Node) bool { if tp == nil { diff --git a/oxlint-presets/antipattern.json b/oxlint-presets/antipattern.json new file mode 100644 index 00000000..ed17be90 --- /dev/null +++ b/oxlint-presets/antipattern.json @@ -0,0 +1,30 @@ +{ + "options": { + "typeAware": true + }, + "plugins": [ + "effecttsgo" + ], + "rules": { + "effecttsgo/catch-unfailable-effect": "warn", + "effecttsgo/effect-fn-iife": "warn", + "effecttsgo/effect-gen-uses-adapter": "warn", + "effecttsgo/effect-in-failure": "warn", + "effecttsgo/effect-in-void-success": "warn", + "effecttsgo/global-error-in-effect-catch": "warn", + "effecttsgo/global-error-in-effect-failure": "warn", + "effecttsgo/layer-merge-all-with-dependencies": "warn", + "effecttsgo/lazy-effect": "warn", + "effecttsgo/lazy-promise-in-effect-sync": "warn", + "effecttsgo/leaking-requirements": "warn", + "effecttsgo/multiple-effect-provide": "warn", + "effecttsgo/prefer-unsafe-constructor": "warn", + "effecttsgo/return-effect-in-gen": "warn", + "effecttsgo/run-effect-inside-effect": "warn", + "effecttsgo/schema-sync-in-effect": "warn", + "effecttsgo/scope-in-layer-effect": "warn", + "effecttsgo/strict-effect-provide": "warn", + "effecttsgo/try-catch-in-effect-gen": "warn", + "effecttsgo/unknown-in-effect-catch": "warn" + } +} diff --git a/oxlint-presets/correctness.json b/oxlint-presets/correctness.json new file mode 100644 index 00000000..28a971a1 --- /dev/null +++ b/oxlint-presets/correctness.json @@ -0,0 +1,28 @@ +{ + "options": { + "typeAware": true + }, + "plugins": [ + "effecttsgo" + ], + "rules": { + "effecttsgo/any-unknown-in-error-context": "warn", + "effecttsgo/class-self-mismatch": "warn", + "effecttsgo/duplicate-package": "warn", + "effecttsgo/effect-fn-implicit-any": "warn", + "effecttsgo/floating-effect": "warn", + "effecttsgo/floating-effect-in-vitest": "warn", + "effecttsgo/generic-effect-services": "warn", + "effecttsgo/missing-effect-context": "warn", + "effecttsgo/missing-effect-error": "warn", + "effecttsgo/missing-layer-context": "warn", + "effecttsgo/missing-return-yield-star": "warn", + "effecttsgo/missing-star-in-yield-effect-gen": "warn", + "effecttsgo/non-object-effect-service-type": "warn", + "effecttsgo/outdated-api": "warn", + "effecttsgo/overridden-schema-constructor": "warn", + "effecttsgo/promise-in-effect-success": "warn", + "effecttsgo/schema-literal-non-finite": "warn", + "effecttsgo/schema-opaque-instance-member": "warn" + } +} diff --git a/oxlint-presets/effect-native.json b/oxlint-presets/effect-native.json new file mode 100644 index 00000000..dae70970 --- /dev/null +++ b/oxlint-presets/effect-native.json @@ -0,0 +1,32 @@ +{ + "options": { + "typeAware": true + }, + "plugins": [ + "effecttsgo" + ], + "rules": { + "effecttsgo/abort-controller-in-effect": "warn", + "effecttsgo/async-function": "warn", + "effecttsgo/crypto-random-uuid": "warn", + "effecttsgo/crypto-random-uuid-in-effect": "warn", + "effecttsgo/extends-native-error": "warn", + "effecttsgo/global-console": "warn", + "effecttsgo/global-console-in-effect": "warn", + "effecttsgo/global-date": "warn", + "effecttsgo/global-date-in-effect": "warn", + "effecttsgo/global-fetch": "warn", + "effecttsgo/global-fetch-in-effect": "warn", + "effecttsgo/global-random": "warn", + "effecttsgo/global-random-in-effect": "warn", + "effecttsgo/global-timers": "warn", + "effecttsgo/global-timers-in-effect": "warn", + "effecttsgo/instance-of-schema": "warn", + "effecttsgo/new-promise": "warn", + "effecttsgo/node-builtin-import": "warn", + "effecttsgo/prefer-schema-over-json": "warn", + "effecttsgo/process-env": "warn", + "effecttsgo/process-env-in-effect": "warn", + "effecttsgo/unsafe-effect-type-assertion": "warn" + } +} diff --git a/oxlint-presets/recommended.json b/oxlint-presets/recommended.json new file mode 100644 index 00000000..b8de604f --- /dev/null +++ b/oxlint-presets/recommended.json @@ -0,0 +1,91 @@ +{ + "options": { + "typeAware": true + }, + "plugins": [ + "effecttsgo" + ], + "rules": { + "effecttsgo/abort-controller-in-effect": "warn", + "effecttsgo/async-function": "warn", + "effecttsgo/catch-all-to-map-error": "warn", + "effecttsgo/catch-chain-to-first-success-of": "warn", + "effecttsgo/catch-tag-to-catch-reason": "warn", + "effecttsgo/catch-to-ignore": "warn", + "effecttsgo/catch-to-or-else-succeed": "warn", + "effecttsgo/catch-unfailable-effect": "warn", + "effecttsgo/class-self-mismatch": "error", + "effecttsgo/crypto-random-uuid": "warn", + "effecttsgo/crypto-random-uuid-in-effect": "warn", + "effecttsgo/duplicate-package": "warn", + "effecttsgo/effect-fn-iife": "warn", + "effecttsgo/effect-fn-implicit-any": "error", + "effecttsgo/effect-fn-opportunity": "warn", + "effecttsgo/effect-gen-uses-adapter": "warn", + "effecttsgo/effect-in-failure": "warn", + "effecttsgo/effect-in-void-success": "warn", + "effecttsgo/effect-map-flatten": "warn", + "effecttsgo/effect-map-void": "warn", + "effecttsgo/effect-succeed-with-void": "warn", + "effecttsgo/extends-native-error": "warn", + "effecttsgo/flat-map-to-map": "warn", + "effecttsgo/floating-effect": "error", + "effecttsgo/floating-effect-in-vitest": "error", + "effecttsgo/generic-effect-services": "warn", + "effecttsgo/global-console": "warn", + "effecttsgo/global-console-in-effect": "warn", + "effecttsgo/global-date": "warn", + "effecttsgo/global-date-in-effect": "warn", + "effecttsgo/global-error-in-effect-catch": "warn", + "effecttsgo/global-error-in-effect-failure": "warn", + "effecttsgo/global-fetch": "warn", + "effecttsgo/global-fetch-in-effect": "warn", + "effecttsgo/global-random": "warn", + "effecttsgo/global-random-in-effect": "warn", + "effecttsgo/global-timers": "warn", + "effecttsgo/global-timers-in-effect": "warn", + "effecttsgo/instance-of-schema": "warn", + "effecttsgo/layer-merge-all-with-dependencies": "warn", + "effecttsgo/lazy-effect": "warn", + "effecttsgo/lazy-promise-in-effect-sync": "warn", + "effecttsgo/leaking-requirements": "warn", + "effecttsgo/missing-effect-context": "error", + "effecttsgo/missing-effect-error": "error", + "effecttsgo/missing-layer-context": "error", + "effecttsgo/missing-return-yield-star": "error", + "effecttsgo/missing-star-in-yield-effect-gen": "error", + "effecttsgo/multiple-catch-tag": "warn", + "effecttsgo/multiple-effect-provide": "warn", + "effecttsgo/new-promise": "warn", + "effecttsgo/node-builtin-import": "warn", + "effecttsgo/non-object-effect-service-type": "error", + "effecttsgo/outdated-api": "warn", + "effecttsgo/overridden-schema-constructor": "error", + "effecttsgo/prefer-schema-over-json": "warn", + "effecttsgo/prefer-typed-schema-decoder": "warn", + "effecttsgo/prefer-unsafe-constructor": "warn", + "effecttsgo/process-env": "warn", + "effecttsgo/process-env-in-effect": "warn", + "effecttsgo/promise-in-effect-success": "warn", + "effecttsgo/redundant-map-error": "warn", + "effecttsgo/redundant-or-die": "warn", + "effecttsgo/redundant-schema-tag-identifier": "warn", + "effecttsgo/return-effect-in-gen": "warn", + "effecttsgo/run-effect-inside-effect": "warn", + "effecttsgo/schema-literal-non-finite": "error", + "effecttsgo/schema-number": "warn", + "effecttsgo/schema-opaque-instance-member": "error", + "effecttsgo/schema-struct-with-tag": "warn", + "effecttsgo/schema-sync-in-effect": "warn", + "effecttsgo/scope-in-layer-effect": "warn", + "effecttsgo/sync-to-succeed": "warn", + "effecttsgo/try-catch-in-effect-gen": "warn", + "effecttsgo/unknown-in-effect-catch": "warn", + "effecttsgo/unnecessary-effect-gen": "warn", + "effecttsgo/unnecessary-fail-yieldable-error": "warn", + "effecttsgo/unnecessary-pipe": "warn", + "effecttsgo/unnecessary-pipe-chain": "warn", + "effecttsgo/unnecessary-typeof-type": "warn", + "effecttsgo/unsafe-effect-type-assertion": "warn" + } +} diff --git a/oxlint-presets/style.json b/oxlint-presets/style.json new file mode 100644 index 00000000..ff2b3f5f --- /dev/null +++ b/oxlint-presets/style.json @@ -0,0 +1,45 @@ +{ + "options": { + "typeAware": true + }, + "plugins": [ + "effecttsgo" + ], + "rules": { + "effecttsgo/catch-all-to-map-error": "warn", + "effecttsgo/catch-chain-to-first-success-of": "warn", + "effecttsgo/catch-tag-to-catch-reason": "warn", + "effecttsgo/catch-to-ignore": "warn", + "effecttsgo/catch-to-or-else-succeed": "warn", + "effecttsgo/deterministic-keys": "warn", + "effecttsgo/effect-do-notation": "warn", + "effecttsgo/effect-fn-opportunity": "warn", + "effecttsgo/effect-map-flatten": "warn", + "effecttsgo/effect-map-void": "warn", + "effecttsgo/effect-succeed-with-void": "warn", + "effecttsgo/flat-map-to-map": "warn", + "effecttsgo/missed-pipeable-opportunity": "warn", + "effecttsgo/missing-effect-service-dependency": "warn", + "effecttsgo/missing-pipeable-signature": "warn", + "effecttsgo/multiple-catch-tag": "warn", + "effecttsgo/nested-effect-gen-yield": "warn", + "effecttsgo/new-schema-class": "warn", + "effecttsgo/prefer-schema-type-property": "warn", + "effecttsgo/prefer-typed-schema-decoder": "warn", + "effecttsgo/redundant-map-error": "warn", + "effecttsgo/redundant-or-die": "warn", + "effecttsgo/redundant-schema-tag-identifier": "warn", + "effecttsgo/schema-number": "warn", + "effecttsgo/schema-struct-with-tag": "warn", + "effecttsgo/schema-union-of-literals": "warn", + "effecttsgo/service-not-as-class": "warn", + "effecttsgo/strict-boolean-expressions": "warn", + "effecttsgo/sync-to-succeed": "warn", + "effecttsgo/unnecessary-arrow-block": "warn", + "effecttsgo/unnecessary-effect-gen": "warn", + "effecttsgo/unnecessary-fail-yieldable-error": "warn", + "effecttsgo/unnecessary-pipe": "warn", + "effecttsgo/unnecessary-pipe-chain": "warn", + "effecttsgo/unnecessary-typeof-type": "warn" + } +} diff --git a/oxlint-schema.json b/oxlint-schema.json index 5549d644..a995cda2 100644 --- a/oxlint-schema.json +++ b/oxlint-schema.json @@ -2410,6 +2410,9 @@ "effecttsgo/prefer-schema-type-property": { "$ref": "#/definitions/RuleNoConfig" }, + "effecttsgo/prefer-typed-schema-decoder": { + "$ref": "#/definitions/RuleNoConfig" + }, "effecttsgo/prefer-unsafe-constructor": { "$ref": "#/definitions/RuleNoConfig" }, diff --git a/package.json b/package.json index d22b4971..8dadcbdf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "check": "repoctl check", "check:repoctl": "pnpm --filter @effect/tsgo-repoctl check", "changeset": "changeset", - "lint": "CGO_ENABLED=0 golangci-lint run ./...", + "lint": "repoctl lint", "test": "repoctl test", "test:repoctl": "pnpm --filter @effect/tsgo-repoctl test", "build": "repoctl build local" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1755ea6c..bc02b93f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,17 +18,17 @@ importers: _packages/tsgo: devDependencies: '@effect/platform-node': - specifier: ^4.0.0-beta.104 - version: 4.0.0-beta.104(effect@4.0.0-beta.104)(ioredis@5.9.2) + specifier: ^4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.2) '@effect/platform-node-shared': - specifier: ^4.0.0-beta.104 - version: 4.0.0-beta.104(effect@4.0.0-beta.104) + specifier: ^4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107) '@types/node': specifier: ^24.3.0 version: 24.10.13 effect: - specifier: ^4.0.0-beta.104 - version: 4.0.0-beta.104 + specifier: ^4.0.0-beta.107 + version: 4.0.0-beta.107 tsdown: specifier: ^0.20.1 version: 0.20.3(typescript@5.9.3) @@ -78,11 +78,11 @@ importers: _tools/repoctl: dependencies: '@effect/platform-node': - specifier: ^4.0.0-beta.104 - version: 4.0.0-beta.104(effect@4.0.0-beta.104)(ioredis@5.9.2) + specifier: ^4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.2) effect: - specifier: ^4.0.0-beta.104 - version: 4.0.0-beta.104 + specifier: ^4.0.0-beta.107 + version: 4.0.0-beta.107 semver: specifier: ^7.7.2 version: 7.8.5 @@ -145,8 +145,8 @@ importers: testdata/tests/effect-v4: dependencies: '@effect/vitest': - specifier: 4.0.0-beta.104 - version: 4.0.0-beta.104(effect@4.0.0-beta.104)(vitest@4.1.10(@types/node@22.19.15)(vite@7.3.1(@types/node@22.19.15)(yaml@2.9.0))) + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10(@types/node@22.19.15)(vite@7.3.1(@types/node@22.19.15)(yaml@2.9.0))) '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -157,8 +157,8 @@ importers: specifier: 4.1.10 version: 4.1.10 effect: - specifier: 4.0.0-beta.104 - version: 4.0.0-beta.104 + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107 fast-check: specifier: ^4.4.0 version: 4.5.3 @@ -178,8 +178,8 @@ importers: specifier: ^22.0.0 version: 22.19.15 effect: - specifier: 4.0.0-beta.104 - version: 4.0.0-beta.104 + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107 fast-check: specifier: ^4.4.0 version: 4.5.3 @@ -196,8 +196,8 @@ importers: specifier: ^22.0.0 version: 22.19.15 effect: - specifier: 4.0.0-beta.104 - version: 4.0.0-beta.104 + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107 fast-check: specifier: ^4.4.0 version: 4.5.3 @@ -208,8 +208,8 @@ importers: testdata/tests/oxlint: dependencies: effect: - specifier: 4.0.0-beta.104 - version: 4.0.0-beta.104 + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107 packages: @@ -303,18 +303,18 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@effect/platform-node-shared@4.0.0-beta.104': - resolution: {integrity: sha512-wZQWlreuCfR+Ip6E93h2Wo56lFdBTflzSErFKxvrAW06/ORa7yZMD4oq4Ow5aU5+AlQgomQSEeBKM7uao4ZewQ==} + '@effect/platform-node-shared@4.0.0-beta.107': + resolution: {integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.104 + effect: ^4.0.0-beta.107 - '@effect/platform-node@4.0.0-beta.104': - resolution: {integrity: sha512-edqD0sRzL3Qow26TnD+RuHNVolFB7c1M/xWGzW0TRc7PzXpRWQawM1haMXOrB8W1UXpg8T8jF5MU56vFhPGJyw==} + '@effect/platform-node@4.0.0-beta.107': + resolution: {integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.104 - ioredis: ^5.7.0 + effect: ^4.0.0-beta.107 + ioredis: '>=5.7.0 <6.0.0' '@effect/vitest@0.27.0': resolution: {integrity: sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ==} @@ -322,11 +322,11 @@ packages: effect: ^3.19.0 vitest: ^3.2.0 - '@effect/vitest@4.0.0-beta.104': - resolution: {integrity: sha512-09AvNl3tJNR7hOEnxE8TqSlBTeqfrWu/s4mIjlv1q6c/r6izkH/qhUrByP87ebbEtCMPJDHHXNcKFs3kEbugbQ==} + '@effect/vitest@4.0.0-beta.107': + resolution: {integrity: sha512-n4/qsx4DnT4dEI/wNgMivxyUeJoeiU1TCSz0WnoHWk/dny40Oxjip2P9IXGQDgPb9fsYVnerF0QRA6nPUuExQA==} peerDependencies: - effect: ^4.0.0-beta.104 - vitest: ^4.1.0 + effect: ^4.0.0-beta.107 + vitest: '>=4.1.0 <5.0.0' '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -952,8 +952,8 @@ packages: effect@3.19.19: resolution: {integrity: sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg==} - effect@4.0.0-beta.104: - resolution: {integrity: sha512-YSSaaMc8gBoHnabYXlgHpKVctsj4ezTSoojdd8SA3NWHoZ7LMPiUDhCnP1ZSOfQ7ly6P6XLhAw216NfLEHfg2A==} + effect@4.0.0-beta.107: + resolution: {integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==} empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} @@ -1582,19 +1582,19 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@effect/platform-node-shared@4.0.0-beta.104(effect@4.0.0-beta.104)': + '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-beta.107)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.104 + effect: 4.0.0-beta.107 ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.104(effect@4.0.0-beta.104)(ioredis@5.9.2)': + '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.2)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.104(effect@4.0.0-beta.104) - effect: 4.0.0-beta.104 + '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107) + effect: 4.0.0-beta.107 ioredis: 5.9.2 mime: 4.1.0 undici: 8.9.0 @@ -1607,9 +1607,9 @@ snapshots: effect: 3.19.19 vitest: 3.2.4(@types/node@22.19.15)(yaml@2.9.0) - '@effect/vitest@4.0.0-beta.104(effect@4.0.0-beta.104)(vitest@4.1.10(@types/node@22.19.15)(vite@7.3.1(@types/node@22.19.15)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10(@types/node@22.19.15)(vite@7.3.1(@types/node@22.19.15)(yaml@2.9.0)))': dependencies: - effect: 4.0.0-beta.104 + effect: 4.0.0-beta.107 vitest: 4.1.10(@types/node@22.19.15)(vite@7.3.1(@types/node@22.19.15)(yaml@2.9.0)) '@emnapi/core@1.8.1': @@ -2063,7 +2063,7 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - effect@4.0.0-beta.104: + effect@4.0.0-beta.107: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 diff --git a/schema.json b/schema.json index 7c509441..29b9e9c1 100644 --- a/schema.json +++ b/schema.json @@ -2135,6 +2135,11 @@ "default": "off", "description": "Disallows Schema.Schema.Type\u003ctypeof X\u003e in favor of typeof X.Type" }, + "preferTypedSchemaDecoder": { + "$ref": "#/definitions/effectLanguageServicePluginSeverityDefinition", + "default": "suggestion", + "description": "Suggests typed Schema decoders when the input is assignable to the schema's Encoded type" + }, "preferUnsafeConstructor": { "$ref": "#/definitions/effectLanguageServicePluginSeverityDefinition", "default": "suggestion", diff --git a/shim/compiler/shim.go b/shim/compiler/shim.go index 69f3a563..be981f02 100644 --- a/shim/compiler/shim.go +++ b/shim/compiler/shim.go @@ -26,6 +26,7 @@ const EmitOnlyJs = compiler.EmitOnlyJs type EmitOptions = compiler.EmitOptions type EmitResult = compiler.EmitResult type FileIncludeReason = compiler.FileIncludeReason +var FilterDiagnosticsForNoEmitOnErrorCallback = compiler.FilterDiagnosticsForNoEmitOnErrorCallback //go:linkname FilterNoEmitSemanticDiagnostics github.com/microsoft/typescript-go/internal/compiler.FilterNoEmitSemanticDiagnostics func FilterNoEmitSemanticDiagnostics(diagnostics []*ast.Diagnostic, options *core.CompilerOptions) []*ast.Diagnostic //go:linkname GetDiagnosticsOfAnyProgram github.com/microsoft/typescript-go/internal/compiler.GetDiagnosticsOfAnyProgram @@ -42,6 +43,8 @@ func NewProgram(opts compiler.ProgramOptions) *compiler.Program type Program = compiler.Program type ProgramLike = compiler.ProgramLike type ProgramOptions = compiler.ProgramOptions +//go:linkname RegisterFilterDiagnosticsForNoEmitOnErrorCallback github.com/microsoft/typescript-go/internal/compiler.RegisterFilterDiagnosticsForNoEmitOnErrorCallback +func RegisterFilterDiagnosticsForNoEmitOnErrorCallback(cb func(*core.CompilerOptions, []*ast.Diagnostic) []*ast.Diagnostic) //go:linkname SortAndDeduplicateDiagnostics github.com/microsoft/typescript-go/internal/compiler.SortAndDeduplicateDiagnostics func SortAndDeduplicateDiagnostics(diagnostics []*ast.Diagnostic) []*ast.Diagnostic type SourceFileMayBeEmittedHost = compiler.SourceFileMayBeEmittedHost diff --git a/testdata/baselines/reference/effect-v3/missingEffectContext_plainAssignment.flows.missingEffectContext_plainAssignment.mermaid b/testdata/baselines/reference/effect-v3/missingEffectContext_plainAssignment.flows.missingEffectContext_plainAssignment.mermaid index c1432fa0..847e9348 100644 --- a/testdata/baselines/reference/effect-v3/missingEffectContext_plainAssignment.flows.missingEffectContext_plainAssignment.mermaid +++ b/testdata/baselines/reference/effect-v3/missingEffectContext_plainAssignment.flows.missingEffectContext_plainAssignment.mermaid @@ -7,12 +7,11 @@ flowchart TB 5[/"type: #lt;A#gt;#40;value: A#41; =#gt; Effect#lt;A, never, never#gt;
node: Effect.succeed"/] 6[/"type: Effect#lt;number, never, ServiceA #124; ServiceB #124; ServiceC#gt;
node: effectWithServices"/] 7[/"type: Effect#lt;number, never, ServiceA #124; ServiceB #124; ServiceC#gt;
node: effectWithServices"/] - 8[/"type:
node: Effect.Effect"/] - 9[/"type: Effect#lt;number, never, ServiceA #124; ServiceB #124; ServiceC#gt;
node: effectWithServices"/] - 10[["type: #lt;A#gt;#40;service: A#41; =#gt; Effect#lt;Context#lt;A#gt;, never, never#gt;
node: export function missingServiceWithGenericType#lt;A#gt;#40;service: A#41; #123;#92;n // @ts-expect-error#92;n const missingServiceA: Effect.Effect#lt;Context.Context#lt;A#gt;#gt; = Effect.context#lt;A#gt;#40;#41;#92;n return missingServiceA#92;n#125;"]] - 11[/"type: Effect#lt;Context#lt;A#gt;, never, never#gt;
node: missingServiceA"/] - 12[/"type: Effect#lt;number, never, ServiceA #124; ServiceB #124; ServiceC#gt;
node: effectWithServices satisfies Effect.Effect#lt;number, never, never#gt;"/] + 8[/"type: Effect#lt;number, never, ServiceA #124; ServiceB #124; ServiceC#gt;
node: effectWithServices"/] + 9[["type: #lt;A#gt;#40;service: A#41; =#gt; Effect#lt;Context#lt;A#gt;, never, never#gt;
node: export function missingServiceWithGenericType#lt;A#gt;#40;service: A#41; #123;#92;n // @ts-expect-error#92;n const missingServiceA: Effect.Effect#lt;Context.Context#lt;A#gt;#gt; = Effect.context#lt;A#gt;#40;#41;#92;n return missingServiceA#92;n#125;"]] + 10[/"type: Effect#lt;Context#lt;A#gt;, never, never#gt;
node: missingServiceA"/] + 11[/"type: Effect#lt;number, never, ServiceA #124; ServiceB #124; ServiceC#gt;
node: effectWithServices satisfies Effect.Effect#lt;number, never, never#gt;"/] 3 -->|"kind: pipe"| 4 5 -->|"kind: transformCallee"| 4 - 11 -->|"kind: potentialReturn"| 10 - 11 -->|"kind: usedBy"| 10 \ No newline at end of file + 10 -->|"kind: potentialReturn"| 9 + 10 -->|"kind: usedBy"| 9 \ No newline at end of file diff --git a/testdata/baselines/reference/effect-v3/missingEffectError_plainAssignment.flows.missingEffectError_plainAssignment.mermaid b/testdata/baselines/reference/effect-v3/missingEffectError_plainAssignment.flows.missingEffectError_plainAssignment.mermaid index a7addb00..13d5e233 100644 --- a/testdata/baselines/reference/effect-v3/missingEffectError_plainAssignment.flows.missingEffectError_plainAssignment.mermaid +++ b/testdata/baselines/reference/effect-v3/missingEffectError_plainAssignment.flows.missingEffectError_plainAssignment.mermaid @@ -7,18 +7,17 @@ flowchart TB 5[/"type: #lt;A#gt;#40;value: A#41; =#gt; Effect#lt;A, never, never#gt;
node: Effect.succeed"/] 6[/"type: Effect#lt;number, ErrorA #124; ErrorB #124; ErrorC, never#gt;
node: effectWithErrors"/] 7[/"type: Effect#lt;number, ErrorA #124; ErrorB #124; ErrorC, never#gt;
node: effectWithErrors"/] - 8[/"type:
node: Effect.Effect"/] - 9[/"type: Effect#lt;number, ErrorA #124; ErrorB #124; ErrorC, never#gt;
node: effectWithErrors"/] - 10[["type: #lt;A#gt;#40;error: A#41; =#gt; Effect#lt;never, never, never#gt;
node: export function missingErrorWithGenericType#lt;A#gt;#40;error: A#41; #123;#92;n // @ts-expect-error#92;n const missingErrorA: Effect.Effect#lt;never#gt; = Effect.fail#40;error#41;#92;n return missingErrorA#92;n#125;"]] - 11[/"type: Effect#lt;never, never, never#gt;
node: missingErrorA"/] - 12[/"type: A
node: error"/] - 13["type: Effect#lt;never, A, never#gt;
callee: Effect.fail
args: #91;#93;"] - 14[/"type: #lt;E#gt;#40;error: E#41; =#gt; Effect#lt;never, E, never#gt;
node: Effect.fail"/] - 15[/"type: Effect#lt;number, ErrorA #124; ErrorB #124; ErrorC, never#gt;
node: effectWithErrors satisfies Effect.Effect#lt;number, never, never#gt;"/] + 8[/"type: Effect#lt;number, ErrorA #124; ErrorB #124; ErrorC, never#gt;
node: effectWithErrors"/] + 9[["type: #lt;A#gt;#40;error: A#41; =#gt; Effect#lt;never, never, never#gt;
node: export function missingErrorWithGenericType#lt;A#gt;#40;error: A#41; #123;#92;n // @ts-expect-error#92;n const missingErrorA: Effect.Effect#lt;never#gt; = Effect.fail#40;error#41;#92;n return missingErrorA#92;n#125;"]] + 10[/"type: Effect#lt;never, never, never#gt;
node: missingErrorA"/] + 11[/"type: A
node: error"/] + 12["type: Effect#lt;never, A, never#gt;
callee: Effect.fail
args: #91;#93;"] + 13[/"type: #lt;E#gt;#40;error: E#41; =#gt; Effect#lt;never, E, never#gt;
node: Effect.fail"/] + 14[/"type: Effect#lt;number, ErrorA #124; ErrorB #124; ErrorC, never#gt;
node: effectWithErrors satisfies Effect.Effect#lt;number, never, never#gt;"/] 3 -->|"kind: pipe"| 4 5 -->|"kind: transformCallee"| 4 - 11 -->|"kind: potentialReturn"| 10 - 12 -->|"kind: pipe"| 13 - 14 -->|"kind: transformCallee"| 13 - 13 -->|"kind: usedBy"| 10 - 11 -->|"kind: usedBy"| 10 \ No newline at end of file + 10 -->|"kind: potentialReturn"| 9 + 11 -->|"kind: pipe"| 12 + 13 -->|"kind: transformCallee"| 12 + 12 -->|"kind: usedBy"| 9 + 10 -->|"kind: usedBy"| 9 \ No newline at end of file diff --git a/testdata/baselines/reference/effect-v4/classImplementsIssue.flows.classImplementsIssue.mermaid b/testdata/baselines/reference/effect-v4/classImplementsIssue.flows.classImplementsIssue.mermaid index c03a21b5..1e132e92 100644 --- a/testdata/baselines/reference/effect-v4/classImplementsIssue.flows.classImplementsIssue.mermaid +++ b/testdata/baselines/reference/effect-v4/classImplementsIssue.flows.classImplementsIssue.mermaid @@ -1,18 +1,17 @@ flowchart TB - 0[/"type:
node: PubSub.PubSub.ReplayWindow"/] + 0[/"type: 1
node: 1"/] 1[/"type: 1
node: 1"/] - 2[/"type: 1
node: 1"/] - 3[["type: #40;#41; =#gt; void
node: close#40;#41; #123;#92;n #125;"]] - 4[["type: #40;#41; =#gt; void
node: fastForward#40;#41; #123;#92;n #125;"]] - 5[["type: #40;#41; =#gt; A #124; undefined
node: take#40;#41;: A #124; undefined #123;#92;n return 1 as A#92;n #125;"]] - 6[/"type: A
node: 1 as A"/] - 7[["type: #40;n: number#41; =#gt; A#91;#93;
node: takeN#40;n: number#41;: Array#lt;A#gt; #123;#92;n return 1 as any#92;n #125;"]] - 8[/"type: any
node: 1 as any"/] - 9[["type: #40;#41; =#gt; A#91;#93;
node: takeAll#40;#41;: Array#lt;A#gt; #123;#92;n return 1 as any#92;n #125;"]] - 10[/"type: any
node: 1 as any"/] - 6 -->|"kind: potentialReturn"| 5 - 6 -->|"kind: usedBy"| 5 - 8 -->|"kind: potentialReturn"| 7 - 8 -->|"kind: usedBy"| 7 - 10 -->|"kind: potentialReturn"| 9 - 10 -->|"kind: usedBy"| 9 \ No newline at end of file + 2[["type: #40;#41; =#gt; void
node: close#40;#41; #123;#92;n #125;"]] + 3[["type: #40;#41; =#gt; void
node: fastForward#40;#41; #123;#92;n #125;"]] + 4[["type: #40;#41; =#gt; A #124; undefined
node: take#40;#41;: A #124; undefined #123;#92;n return 1 as A#92;n #125;"]] + 5[/"type: A
node: 1 as A"/] + 6[["type: #40;n: number#41; =#gt; A#91;#93;
node: takeN#40;n: number#41;: Array#lt;A#gt; #123;#92;n return 1 as any#92;n #125;"]] + 7[/"type: any
node: 1 as any"/] + 8[["type: #40;#41; =#gt; A#91;#93;
node: takeAll#40;#41;: Array#lt;A#gt; #123;#92;n return 1 as any#92;n #125;"]] + 9[/"type: any
node: 1 as any"/] + 5 -->|"kind: potentialReturn"| 4 + 5 -->|"kind: usedBy"| 4 + 7 -->|"kind: potentialReturn"| 6 + 7 -->|"kind: usedBy"| 6 + 9 -->|"kind: potentialReturn"| 8 + 9 -->|"kind: usedBy"| 8 \ No newline at end of file diff --git a/testdata/baselines/reference/effect-v4/interfaceExtendsIssue.flows.interfaceExtendsIssue.mermaid b/testdata/baselines/reference/effect-v4/interfaceExtendsIssue.flows.interfaceExtendsIssue.mermaid index c3164fe9..4360f955 100644 --- a/testdata/baselines/reference/effect-v4/interfaceExtendsIssue.flows.interfaceExtendsIssue.mermaid +++ b/testdata/baselines/reference/effect-v4/interfaceExtendsIssue.flows.interfaceExtendsIssue.mermaid @@ -1,2 +1 @@ -flowchart TB - 0[/"type:
node: Data.TaggedEnum.WithGenerics"/] \ No newline at end of file +flowchart TB \ No newline at end of file diff --git a/testdata/baselines/reference/effect-v4/interfaceExtendsIssueNoPlugin.flows.interfaceExtendsIssueNoPlugin.mermaid b/testdata/baselines/reference/effect-v4/interfaceExtendsIssueNoPlugin.flows.interfaceExtendsIssueNoPlugin.mermaid index c3164fe9..4360f955 100644 --- a/testdata/baselines/reference/effect-v4/interfaceExtendsIssueNoPlugin.flows.interfaceExtendsIssueNoPlugin.mermaid +++ b/testdata/baselines/reference/effect-v4/interfaceExtendsIssueNoPlugin.flows.interfaceExtendsIssueNoPlugin.mermaid @@ -1,2 +1 @@ -flowchart TB - 0[/"type:
node: Data.TaggedEnum.WithGenerics"/] \ No newline at end of file +flowchart TB \ No newline at end of file diff --git a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.errors.txt b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.errors.txt index 6c84e0a6..815761a5 100644 --- a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.errors.txt +++ b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.errors.txt @@ -12,9 +12,11 @@ Effect version: 4.0.0 /.src/preferTypedSchemaDecoder.ts(29,42): warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeSync` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownSync`. effect(preferTypedSchemaDecoder) /.src/preferTypedSchemaDecoder.ts(30,22): warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeSync` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownSync`. effect(preferTypedSchemaDecoder) /.src/preferTypedSchemaDecoder.ts(33,35): warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeSync` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownSync`. effect(preferTypedSchemaDecoder) +/.src/preferTypedSchemaDecoder.ts(65,31): warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeEffect` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownEffect`. effect(preferTypedSchemaDecoder) +/.src/preferTypedSchemaDecoder.ts(67,67): warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeEffect` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownEffect`. effect(preferTypedSchemaDecoder) -==== /.src/preferTypedSchemaDecoder.ts (11 errors) ==== +==== /.src/preferTypedSchemaDecoder.ts (13 errors) ==== // @effect-diagnostics *:off // @effect-diagnostics preferTypedSchemaDecoder:warning @@ -94,3 +96,18 @@ Effect version: 4.0.0 decodeGeneric(person) decodeNestedGeneric(person) + // Regression test for https://github.com/Effect-TS/tsgo/issues/572. + const NamedPerson = Schema.Struct({ name: Schema.String }) + + function makeFields(name: string): { readonly name: string } { + return { name } + } + + export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + ~~~~~~~~~~~~~~~~~~~ +!!! warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeEffect` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownEffect`. effect(preferTypedSchemaDecoder) + + export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + ~~~~~~~~~~~~~~~~~~~ +!!! warning TS377112: This input is already assignable to the schema's Encoded type. Use `decodeEffect` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownEffect`. effect(preferTypedSchemaDecoder) + diff --git a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.flows.preferTypedSchemaDecoder.mermaid b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.flows.preferTypedSchemaDecoder.mermaid index 17108cf2..8dbaca04 100644 --- a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.flows.preferTypedSchemaDecoder.mermaid +++ b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.flows.preferTypedSchemaDecoder.mermaid @@ -36,6 +36,20 @@ flowchart TB 34[/"type: #123; name: string; coordinates: #91;number, number#93;; #125;
node: person"/] 35["type: #123; readonly name: string; readonly coordinates: readonly #91;number, number#93;; #125;
callee: decodeNestedGeneric
args: #91;#93;"] 36[/"type: #lt;T extends string#gt;#40;input: #123; readonly name: T; readonly coordinates: #91;number, number#93;; #125;#41; =#gt; #123; readonly name: string; readonly coordinates: readonly #91;number, number#93;; #125;
node: decodeNestedGeneric"/] + 37[/"type: #123; name: String; #125;
node: #123; name: Schema.String #125;"/] + 38["type: Struct#lt;#123; readonly name: String; #125;#gt;
callee: Schema.Struct
args: #91;#93;"] + 39[/"type: #lt;const Fields extends Struct.Fields#gt;#40;fields: Fields#41; =#gt; Struct#lt;Fields#gt;
node: Schema.Struct"/] + 40[["type: #40;name: string#41; =#gt; #123; readonly name: string; #125;
node: function makeFields#40;name: string#41;: #123; readonly name: string #125; #123;#92;n return #123; name #125;#92;n#125;"]] + 41[/"type: #123; name: string; #125;
node: #123; name #125;"/] + 42[/"type: Effect#lt;#123; readonly name: string; #125;, SchemaError, never#gt;
node: Schema.decodeUnknownEffect#40;NamedPerson#41;#40;makeFields#40;#quot;Ada#quot;#41;#41;"/] + 43[/"type: #quot;Ada#quot;
node: #quot;Ada#quot;"/] + 44["type: #123; readonly name: string; #125;
callee: makeFields
args: #91;#93;"] + 45[/"type: #40;name: string#41; =#gt; #123; readonly name: string; #125;
node: makeFields"/] + 46[/"type: #quot;Ada#quot;
node: #quot;Ada#quot;"/] + 47["type: #123; readonly name: string; #125;
callee: makeFields
args: #91;#93;"] + 48["type: Effect#lt;#123; readonly name: string; #125;, SchemaError, never#gt;
callee: Schema.decodeUnknownEffect
args: #91;NamedPerson#93;"] + 49[/"type: #lt;S extends Constraint#gt;#40;schema: S, options?: ParseOptions #124; undefined#41; =#gt; #40;input: unknown, options?: ParseOptions #124; undefined#41; =#gt; Effect#lt;S#91;#quot;Type#quot;#93;, SchemaError, S#91;#quot;DecodingServices#quot;#93;#gt;
node: Schema.decodeUnknownEffect"/] + 50[/"type: Struct#lt;#123; readonly name: String; #125;#gt;
node: NamedPerson"/] 1 -->|"kind: pipe"| 2 3 -->|"kind: transformCallee"| 2 2 -->|"kind: usedBy"| 0 @@ -51,4 +65,15 @@ flowchart TB 31 -->|"kind: pipe"| 32 33 -->|"kind: transformCallee"| 32 34 -->|"kind: pipe"| 35 - 36 -->|"kind: transformCallee"| 35 \ No newline at end of file + 36 -->|"kind: transformCallee"| 35 + 37 -->|"kind: pipe"| 38 + 39 -->|"kind: transformCallee"| 38 + 41 -->|"kind: potentialReturn"| 40 + 41 -->|"kind: usedBy"| 40 + 43 -->|"kind: pipe"| 44 + 45 -->|"kind: transformCallee"| 44 + 44 -->|"kind: usedBy"| 42 + 46 -->|"kind: pipe"| 47 + 49 -->|"kind: transformCallee"| 48 + 50 -->|"kind: transformArg"| 48 + 47 -->|"kind: pipe"| 48 \ No newline at end of file diff --git a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.pipings.txt b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.pipings.txt index 367eb605..c7f96f7a 100644 --- a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.pipings.txt +++ b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.pipings.txt @@ -1,4 +1,4 @@ -==== /.src/preferTypedSchemaDecoder.ts (23 flows) ==== +==== /.src/preferTypedSchemaDecoder.ts (26 flows) ==== === Piping Flow === Location: 8:15 - 11:3 @@ -321,3 +321,53 @@ Transformations (1): callee: decodeNestedGeneric args: (constant) outType: { readonly name: string; readonly coordinates: readonly [number, number]; } + +=== Piping Flow === +Location: 59:20 - 59:59 +Node: Schema.Struct({ name: Schema.String }) +Node Kind: KindCallExpression + +Subject: { name: Schema.String } +Subject Type: { name: String; } + +Transformations (1): + [0] kind: call + callee: Schema.Struct + args: (constant) + outType: Struct<{ readonly name: String; }> + +=== Piping Flow === +Location: 65:23 - 65:82 +Node: Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) +Node Kind: KindCallExpression + +Subject: "Ada" +Subject Type: "Ada" + +Transformations (2): + [0] kind: call + callee: makeFields + args: (constant) + outType: { readonly name: string; } + [1] kind: call + callee: Schema.decodeUnknownEffect(NamedPerson) + args: (constant) + outType: Effect<{ readonly name: string; }, SchemaError, never> + +=== Piping Flow === +Location: 67:35 - 67:100 +Node: pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) +Node Kind: KindCallExpression + +Subject: "Ada" +Subject Type: "Ada" + +Transformations (2): + [0] kind: pipe + callee: makeFields + args: (constant) + outType: { readonly name: string; } + [1] kind: pipe + callee: Schema.decodeUnknownEffect + args: [NamedPerson] + outType: Effect<{ readonly name: string; }, SchemaError, never> diff --git a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.quickfixes.txt b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.quickfixes.txt index 10c6ba50..8f015cdd 100644 --- a/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.quickfixes.txt +++ b/testdata/baselines/reference/effect-v4/preferTypedSchemaDecoder.quickfixes.txt @@ -54,6 +54,16 @@ Fix 1: "Disable preferTypedSchemaDecoder for entire file" Fix 2: "Replace with decodeSync" +[D12] (65:31-65:50) TS377112: This input is already assignable to the schema's Encoded type. Use `decodeEffect` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownEffect`. effect(preferTypedSchemaDecoder) + Fix 0: "Disable preferTypedSchemaDecoder for this line" + Fix 1: "Disable preferTypedSchemaDecoder for entire file" + Fix 2: "Replace with decodeEffect" + +[D13] (67:67-67:86) TS377112: This input is already assignable to the schema's Encoded type. Use `decodeEffect` to preserve compile-time type checking instead of discarding the input type through `decodeUnknownEffect`. effect(preferTypedSchemaDecoder) + Fix 0: "Disable preferTypedSchemaDecoder for this line" + Fix 1: "Disable preferTypedSchemaDecoder for entire file" + Fix 2: "Replace with decodeEffect" + === Quick Fix Application Results === === [D1] Fix 0: "Disable preferTypedSchemaDecoder for this line" === @@ -122,6 +132,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D2] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -189,6 +210,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D3] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -256,6 +288,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D4] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -323,6 +366,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D5] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -390,6 +444,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D6] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -457,6 +522,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D7] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -524,6 +600,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D8] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -591,6 +678,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D9] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -658,6 +756,17 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + === [D10] Fix 0: "Disable preferTypedSchemaDecoder for this line" === skipped by default @@ -731,3 +840,170 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + + +=== [D12] Fix 0: "Disable preferTypedSchemaDecoder for this line" === +skipped by default + +=== [D12] Fix 1: "Disable preferTypedSchemaDecoder for entire file" === +skipped by default + +=== [D12] Fix 2: "Replace with decodeEffect" === + +--- file:///.src/preferTypedSchemaDecoder.ts --- +// @effect-diagnostics *:off +// @effect-diagnostics preferTypedSchemaDecoder:warning + +import { pipe, Schema } from "effect" +import { decodeUnknownSync } from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" + +const Person = Schema.Struct({ + name: Schema.String, + coordinates: Schema.Tuple([Schema.Number, Schema.Number]) +}) + +const person = { + name: "Ada", + coordinates: [1, 2] as [number, number] +} + +// Typed variables and contextually typed literals are reported. +export const sync = Schema.decodeUnknownSync(Person)(person) +export const literal = Schema.decodeUnknownSync(Person)({ name: "Ada", coordinates: [1, 2] }) +export const effect = Schema.decodeUnknownEffect(Person)(person) +export const exit = Schema.decodeUnknownExit(Person)(person) +export const option = Schema.decodeUnknownOption(Person)(person) +export const result = Schema.decodeUnknownResult(Person)(person) +export const promise = Schema.decodeUnknownPromise(Person)(person) + +// SchemaParser APIs and piping forms are normalized through piping flows. +export const parser = SchemaParser.decodeUnknownSync(Person)(person) +export const piped = pipe(person, Schema.decodeUnknownSync(Person)) +export const named = decodeUnknownSync(Person)(person) + +// Application options do not prevent recognizing the decoder application. +export const withOptions = Schema.decodeUnknownSync(Person)({ name: "Ada", coordinates: [1, 2] }, { errors: "all" }) + +declare const unknownInput: unknown +declare const anyInput: any +declare const wrongInput: { readonly name: number } + +// Unknown, any, incompatible, and unresolved generic inputs are valid uses. +Schema.decodeUnknownSync(Person)(unknownInput) +Schema.decodeUnknownSync(Person)(anyInput) +Schema.decodeUnknownSync(Person)(wrongInput) +Schema.decodeUnknownSync(Person)({ name: 1, coordinates: [1, 2] }) +Schema.decodeUnknownSync(Person)({ name: "Ada" }) +Schema.decodeUnknownSync(Person)({ name: "Ada", coordinates: [1] }) + +function decodeGeneric(input: T) { + return Schema.decodeUnknownSync(Person)(input) +} + +function decodeNestedGeneric(input: { readonly name: T; readonly coordinates: [number, number] }) { + return Schema.decodeUnknownSync(Person)(input) +} + +decodeGeneric(person) +decodeNestedGeneric(person) + +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) + + +=== [D13] Fix 0: "Disable preferTypedSchemaDecoder for this line" === +skipped by default + +=== [D13] Fix 1: "Disable preferTypedSchemaDecoder for entire file" === +skipped by default + +=== [D13] Fix 2: "Replace with decodeEffect" === + +--- file:///.src/preferTypedSchemaDecoder.ts --- +// @effect-diagnostics *:off +// @effect-diagnostics preferTypedSchemaDecoder:warning + +import { pipe, Schema } from "effect" +import { decodeUnknownSync } from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" + +const Person = Schema.Struct({ + name: Schema.String, + coordinates: Schema.Tuple([Schema.Number, Schema.Number]) +}) + +const person = { + name: "Ada", + coordinates: [1, 2] as [number, number] +} + +// Typed variables and contextually typed literals are reported. +export const sync = Schema.decodeUnknownSync(Person)(person) +export const literal = Schema.decodeUnknownSync(Person)({ name: "Ada", coordinates: [1, 2] }) +export const effect = Schema.decodeUnknownEffect(Person)(person) +export const exit = Schema.decodeUnknownExit(Person)(person) +export const option = Schema.decodeUnknownOption(Person)(person) +export const result = Schema.decodeUnknownResult(Person)(person) +export const promise = Schema.decodeUnknownPromise(Person)(person) + +// SchemaParser APIs and piping forms are normalized through piping flows. +export const parser = SchemaParser.decodeUnknownSync(Person)(person) +export const piped = pipe(person, Schema.decodeUnknownSync(Person)) +export const named = decodeUnknownSync(Person)(person) + +// Application options do not prevent recognizing the decoder application. +export const withOptions = Schema.decodeUnknownSync(Person)({ name: "Ada", coordinates: [1, 2] }, { errors: "all" }) + +declare const unknownInput: unknown +declare const anyInput: any +declare const wrongInput: { readonly name: number } + +// Unknown, any, incompatible, and unresolved generic inputs are valid uses. +Schema.decodeUnknownSync(Person)(unknownInput) +Schema.decodeUnknownSync(Person)(anyInput) +Schema.decodeUnknownSync(Person)(wrongInput) +Schema.decodeUnknownSync(Person)({ name: 1, coordinates: [1, 2] }) +Schema.decodeUnknownSync(Person)({ name: "Ada" }) +Schema.decodeUnknownSync(Person)({ name: "Ada", coordinates: [1] }) + +function decodeGeneric(input: T) { + return Schema.decodeUnknownSync(Person)(input) +} + +function decodeNestedGeneric(input: { readonly name: T; readonly coordinates: [number, number] }) { + return Schema.decodeUnknownSync(Person)(input) +} + +decodeGeneric(person) +decodeNestedGeneric(person) + +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeEffect(NamedPerson)) + diff --git a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.errors.txt b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.errors.txt index b4be7406..73f7eb97 100644 --- a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.errors.txt +++ b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.errors.txt @@ -4,7 +4,10 @@ Effect version: 4.0.0 ==== /.src/schemaMutableKey_ts2322.ts (0 errors) ==== - import { Schema, Struct as Struct_, SchemaAST } from "effect" + import { Schema, Struct as Struct_, SchemaAST } from "effect" + declare module "effect/SchemaAST" { + export function optionalKey(ast: A): A + } interface optionalKeyLambda extends Struct_.Lambda { diff --git a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.flows.schemaMutableKey_ts2322.mermaid b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.flows.schemaMutableKey_ts2322.mermaid index 41694078..27a66203 100644 --- a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.flows.schemaMutableKey_ts2322.mermaid +++ b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.flows.schemaMutableKey_ts2322.mermaid @@ -1,10 +1,10 @@ flowchart TB - 0[/"type:
node: Struct_.Lambda"/] + 0[["type: #lt;A extends SchemaAST.AST#gt;#40;ast: A#41; =#gt; A
node: export function optionalKey#lt;A extends SchemaAST.AST#gt;#40;ast: A#41;: A"]] 1[["type: #40;schema: Top#41; =#gt; optionalKey#lt;Top#gt;
node: #40;schema#41; =#gt; Schema.make#40;SchemaAST.optionalKey#40;schema.ast#41;, #123; schema #125;#41;"]] 2[/"type: optionalKey#lt;Top#gt;
node: Schema.make#40;SchemaAST.optionalKey#40;schema.ast#41;, #123; schema #125;#41;"/] 3[/"type: AST
node: schema.ast"/] 4["type: AST
callee: SchemaAST.optionalKey
args: #91;#93;"] - 5[/"type: #lt;A extends AST#gt;#40;ast: A#41; =#gt; A
node: SchemaAST.optionalKey"/] + 5[/"type: #lt;A extends SchemaAST.AST#gt;#40;ast: A#41; =#gt; A
node: SchemaAST.optionalKey"/] 6["type: optionalKeyLambda
callee: Struct_.lambda
args: #91;#93;"] 7[/"type: #lt;L extends #40;a: any#41; =#gt; any#gt;#40;f: #40;a: Parameters#lt;L#gt;#91;0#93;#41; =#gt; ReturnType#lt;L#gt;#41; =#gt; L
node: Struct_.lambda"/] 3 -->|"kind: pipe"| 4 diff --git a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.pipings.txt b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.pipings.txt index 1882a4e1..4971c764 100644 --- a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.pipings.txt +++ b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322.pipings.txt @@ -1,7 +1,7 @@ ==== /.src/schemaMutableKey_ts2322.ts (2 flows) ==== === Piping Flow === -Location: 30:27 - 30:133 +Location: 33:27 - 33:133 Node: Struct_.lambda((schema) => Schema.make(SchemaAST.optionalKey(schema.ast), { schema })) Node Kind: KindCallExpression @@ -15,7 +15,7 @@ Transformations (1): outType: optionalKeyLambda === Piping Flow === -Location: 30:86 - 30:119 +Location: 33:86 - 33:119 Node: SchemaAST.optionalKey(schema.ast) Node Kind: KindCallExpression diff --git a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.errors.txt b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.errors.txt index 2ff778af..b80aae37 100644 --- a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.errors.txt +++ b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.errors.txt @@ -15,7 +15,10 @@ Effect version: 4.0.0 ==== /.src/schemaMutableKey_ts2322_pluginDisabled.ts (0 errors) ==== - import { Schema, Struct as Struct_, SchemaAST } from "effect" + import { Schema, Struct as Struct_, SchemaAST } from "effect" + declare module "effect/SchemaAST" { + export function optionalKey
(ast: A): A + } interface optionalKeyLambda extends Struct_.Lambda { diff --git a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.flows.schemaMutableKey_ts2322_pluginDisabled.mermaid b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.flows.schemaMutableKey_ts2322_pluginDisabled.mermaid index 41694078..27a66203 100644 --- a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.flows.schemaMutableKey_ts2322_pluginDisabled.mermaid +++ b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.flows.schemaMutableKey_ts2322_pluginDisabled.mermaid @@ -1,10 +1,10 @@ flowchart TB - 0[/"type:
node: Struct_.Lambda"/] + 0[["type: #lt;A extends SchemaAST.AST#gt;#40;ast: A#41; =#gt; A
node: export function optionalKey#lt;A extends SchemaAST.AST#gt;#40;ast: A#41;: A"]] 1[["type: #40;schema: Top#41; =#gt; optionalKey#lt;Top#gt;
node: #40;schema#41; =#gt; Schema.make#40;SchemaAST.optionalKey#40;schema.ast#41;, #123; schema #125;#41;"]] 2[/"type: optionalKey#lt;Top#gt;
node: Schema.make#40;SchemaAST.optionalKey#40;schema.ast#41;, #123; schema #125;#41;"/] 3[/"type: AST
node: schema.ast"/] 4["type: AST
callee: SchemaAST.optionalKey
args: #91;#93;"] - 5[/"type: #lt;A extends AST#gt;#40;ast: A#41; =#gt; A
node: SchemaAST.optionalKey"/] + 5[/"type: #lt;A extends SchemaAST.AST#gt;#40;ast: A#41; =#gt; A
node: SchemaAST.optionalKey"/] 6["type: optionalKeyLambda
callee: Struct_.lambda
args: #91;#93;"] 7[/"type: #lt;L extends #40;a: any#41; =#gt; any#gt;#40;f: #40;a: Parameters#lt;L#gt;#91;0#93;#41; =#gt; ReturnType#lt;L#gt;#41; =#gt; L
node: Struct_.lambda"/] 3 -->|"kind: pipe"| 4 diff --git a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.pipings.txt b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.pipings.txt index 32521831..336f52b8 100644 --- a/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.pipings.txt +++ b/testdata/baselines/reference/effect-v4/schemaMutableKey_ts2322_pluginDisabled.pipings.txt @@ -1,7 +1,7 @@ ==== /.src/schemaMutableKey_ts2322_pluginDisabled.ts (2 flows) ==== === Piping Flow === -Location: 30:27 - 30:133 +Location: 33:27 - 33:133 Node: Struct_.lambda((schema) => Schema.make(SchemaAST.optionalKey(schema.ast), { schema })) Node Kind: KindCallExpression @@ -15,7 +15,7 @@ Transformations (1): outType: optionalKeyLambda === Piping Flow === -Location: 30:86 - 30:119 +Location: 33:86 - 33:119 Node: SchemaAST.optionalKey(schema.ast) Node Kind: KindCallExpression diff --git a/testdata/tests/effect-v4-document-symbols/package.json b/testdata/tests/effect-v4-document-symbols/package.json index 88a5ad70..71d47e69 100644 --- a/testdata/tests/effect-v4-document-symbols/package.json +++ b/testdata/tests/effect-v4-document-symbols/package.json @@ -2,7 +2,7 @@ "name": "effect-v4-document-symbols-tests", "private": true, "dependencies": { - "effect": "4.0.0-beta.104", + "effect": "4.0.0-beta.107", "@standard-schema/spec": "^1.1.0", "fast-check": "^4.4.0", "pure-rand": "^7.0.0", diff --git a/testdata/tests/effect-v4-refactors/package.json b/testdata/tests/effect-v4-refactors/package.json index a34f99cc..8a783a0e 100644 --- a/testdata/tests/effect-v4-refactors/package.json +++ b/testdata/tests/effect-v4-refactors/package.json @@ -2,7 +2,7 @@ "name": "effect-v4-refactors-tests", "private": true, "dependencies": { - "effect": "4.0.0-beta.104", + "effect": "4.0.0-beta.107", "@standard-schema/spec": "^1.1.0", "fast-check": "^4.4.0", "pure-rand": "^7.0.0", diff --git a/testdata/tests/effect-v4/package.json b/testdata/tests/effect-v4/package.json index f8cf2875..e4c47383 100644 --- a/testdata/tests/effect-v4/package.json +++ b/testdata/tests/effect-v4/package.json @@ -2,11 +2,11 @@ "name": "effect-v4-tests", "private": true, "dependencies": { - "@effect/vitest": "4.0.0-beta.104", + "@effect/vitest": "4.0.0-beta.107", "@standard-schema/spec": "^1.1.0", "@types/node": "^22.0.0", "@vitest/runner": "4.1.10", - "effect": "4.0.0-beta.104", + "effect": "4.0.0-beta.107", "fast-check": "^4.4.0", "pure-rand": "^7.0.0", "vitest": "4.1.10" diff --git a/testdata/tests/effect-v4/preferTypedSchemaDecoder.ts b/testdata/tests/effect-v4/preferTypedSchemaDecoder.ts index b5205ca0..a15fe55c 100644 --- a/testdata/tests/effect-v4/preferTypedSchemaDecoder.ts +++ b/testdata/tests/effect-v4/preferTypedSchemaDecoder.ts @@ -54,3 +54,14 @@ function decodeNestedGeneric(input: { readonly name: T; readon decodeGeneric(person) decodeNestedGeneric(person) + +// Regression test for https://github.com/Effect-TS/tsgo/issues/572. +const NamedPerson = Schema.Struct({ name: Schema.String }) + +function makeFields(name: string): { readonly name: string } { + return { name } +} + +export const decoded = Schema.decodeUnknownEffect(NamedPerson)(makeFields("Ada")) + +export const pipedCallExpression = pipe("Ada", makeFields, Schema.decodeUnknownEffect(NamedPerson)) diff --git a/testdata/tests/effect-v4/schemaMutableKey_ts2322.ts b/testdata/tests/effect-v4/schemaMutableKey_ts2322.ts index 22f713e4..6d40a4fb 100644 --- a/testdata/tests/effect-v4/schemaMutableKey_ts2322.ts +++ b/testdata/tests/effect-v4/schemaMutableKey_ts2322.ts @@ -1,4 +1,7 @@ -import { Schema, Struct as Struct_, SchemaAST } from "effect" +import { Schema, Struct as Struct_, SchemaAST } from "effect" +declare module "effect/SchemaAST" { + export function optionalKey
(ast: A): A +} interface optionalKeyLambda extends Struct_.Lambda { diff --git a/testdata/tests/effect-v4/schemaMutableKey_ts2322_pluginDisabled.ts b/testdata/tests/effect-v4/schemaMutableKey_ts2322_pluginDisabled.ts index 54e76534..bff487e2 100644 --- a/testdata/tests/effect-v4/schemaMutableKey_ts2322_pluginDisabled.ts +++ b/testdata/tests/effect-v4/schemaMutableKey_ts2322_pluginDisabled.ts @@ -9,7 +9,10 @@ } // @filename: schemaMutableKey_ts2322_pluginDisabled.ts -import { Schema, Struct as Struct_, SchemaAST } from "effect" +import { Schema, Struct as Struct_, SchemaAST } from "effect" +declare module "effect/SchemaAST" { + export function optionalKey(ast: A): A +} interface optionalKeyLambda extends Struct_.Lambda { diff --git a/testdata/tests/oxlint/.oxlintrc-recommended-override.json b/testdata/tests/oxlint/.oxlintrc-recommended-override.json new file mode 100644 index 00000000..b80c8e13 --- /dev/null +++ b/testdata/tests/oxlint/.oxlintrc-recommended-override.json @@ -0,0 +1,6 @@ +{ + "extends": ["../../../oxlint-presets/recommended.json"], + "rules": { + "effecttsgo/global-date": "off" + } +} diff --git a/testdata/tests/oxlint/.oxlintrc-recommended.json b/testdata/tests/oxlint/.oxlintrc-recommended.json new file mode 100644 index 00000000..7621794a --- /dev/null +++ b/testdata/tests/oxlint/.oxlintrc-recommended.json @@ -0,0 +1,3 @@ +{ + "extends": ["../../../oxlint-presets/recommended.json"] +} diff --git a/testdata/tests/oxlint/global-date.ts b/testdata/tests/oxlint/global-date.ts new file mode 100644 index 00000000..4c890ca3 --- /dev/null +++ b/testdata/tests/oxlint/global-date.ts @@ -0,0 +1 @@ +export const now = Date.now() diff --git a/testdata/tests/oxlint/package.json b/testdata/tests/oxlint/package.json index 699c606d..7cfaa681 100644 --- a/testdata/tests/oxlint/package.json +++ b/testdata/tests/oxlint/package.json @@ -2,6 +2,6 @@ "name": "oxlint-profile-tests", "private": true, "dependencies": { - "effect": "4.0.0-beta.104" + "effect": "4.0.0-beta.107" } } diff --git a/testdata/tests/oxlint/smoke.mjs b/testdata/tests/oxlint/smoke.mjs index 3f56bd35..b6a1f412 100644 --- a/testdata/tests/oxlint/smoke.mjs +++ b/testdata/tests/oxlint/smoke.mjs @@ -12,6 +12,7 @@ assert.ok(existsSync(tsgolint), `tsgolint executable does not exist: ${tsgolint} const fixture = dirname(fileURLToPath(import.meta.url)) const oxlint = join(repositoryRoot, "oxlint", "apps", "oxlint", "dist", "cli.js") +const packageDirectory = join(repositoryRoot, "_packages", "tsgo") const env = { ...process.env, OXLINT_TSGOLINT_PATH: tsgolint @@ -23,6 +24,18 @@ const run = (...args) => spawnSync(process.execPath, [oxlint, ...args], { env }) +const packagePreset = spawnSync(process.execPath, [ + "--input-type=module", + "--eval", + `import { recommended } from "@effect/tsgo/oxlint-presets"; + import recommendedJson from "@effect/tsgo/oxlint-presets/recommended.json" with { type: "json" }; + if (recommended.rules["effecttsgo/global-date"] !== "warn" || recommendedJson.rules["effecttsgo/global-date"] !== "warn") process.exit(1);` +], { + cwd: packageDirectory, + encoding: "utf8" +}) +assert.equal(packagePreset.status, 0, packagePreset.stderr) + const rules = run("--rules", "--format", "json") assert.equal(rules.status, 0, rules.stderr) const registeredRules = JSON.parse(rules.stdout) @@ -49,4 +62,12 @@ const disabled = run("--type-aware", "--config", ".oxlintrc.json", "disabled.ts" assert.equal(disabled.status, 0, disabled.stderr) assert.doesNotMatch(`${disabled.stdout}\n${disabled.stderr}`, /effecttsgo\(floating-effect\)/) +const recommended = run("--config", ".oxlintrc-recommended.json", "global-date.ts") +assert.equal(recommended.status, 0, recommended.stderr) +assert.match(`${recommended.stdout}\n${recommended.stderr}`, /effecttsgo\(global-date\)/) + +const recommendedOverride = run("--config", ".oxlintrc-recommended-override.json", "global-date.ts") +assert.equal(recommendedOverride.status, 0, recommendedOverride.stderr) +assert.doesNotMatch(`${recommendedOverride.stdout}\n${recommendedOverride.stderr}`, /effecttsgo\(global-date\)/) + console.log("Oxlint profile smoke test passed")