* Fix paid organization creation limit * Clarify stack PR URL reporting * Clarify clickable stack PR links
Unified · Split
| @@ -79,6 +79,12 @@ Use this to understand local stack metadata, current branch position, missing · 78 unmodified lines | |||
| 79 | parents, tracked PR numbers, and PR titles when GitHub is available. It is | 79 | parents, tracked PR numbers, and PR titles when GitHub is available. It is |
| 80 | opinionated: backup branches are hidden, and when the current branch is | 80 | opinionated: backup branches are hidden, and when the current branch is |
| 81 | stack-relevant it focuses on that stack instead of listing every local branch. | 81 | stack-relevant it focuses on that stack instead of listing every local branch. |
| 82 | When relaying a stack view to the user, always include the full PR URLs for | ||
| 83 | every branch that has a PR, not just PR numbers or titles. If `stack status` | ||
| 84 | does not print a URL for a branch, query GitHub with `gh pr view`/`gh pr list` | ||
| 85 | and include the URL in the shown stack view. Use Markdown links or plain URL | ||
| 86 | lines for user-facing stack views so the PR links are clickable; do not hide the | ||
| 87 | only copy of the PR URLs inside a fenced code block. | ||
| 82 | 88 | ||
| 83 | Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base | 89 | Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base |
| 84 | inference before mutation. | 90 | inference before mutation. |
| @@ -129,6 +135,9 @@ nonzero if any stack failed. · 44 unmodified lines | |||
| 129 | Sync output is intentionally outcome-oriented. It should show the stack tree with | 135 | Sync output is intentionally outcome-oriented. It should show the stack tree with |
| 130 | icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It | 136 | icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It |
| 131 | should not default to internal phase logs like fetch, inspect, or reconcile. | 137 | should not default to internal phase logs like fetch, inspect, or reconcile. |
| 138 | When you show a stack tree to the user, include full PR URLs alongside each PR | ||
| 139 | entry so the stack view is directly actionable. Prefer Markdown list/tree output | ||
| 140 | with clickable links over fenced code blocks when relaying PR URLs to a user. | ||
| 132 | 141 | ||
| 133 | If a replay fails, `stack sync` aborts the failed cherry-pick, restores the | 142 | If a replay fails, `stack sync` aborts the failed cherry-pick, restores the |
| 134 | original branch, deletes the temporary replay branch, keeps backups and the undo | 143 | original branch, deletes the temporary replay branch, keeps backups and the undo |
| @@ -29,6 +29,7 @@ · 28 unmodified lines | |||
| 29 | "executor/no-try-catch-or-throw": "error", | 29 | "executor/no-try-catch-or-throw": "error", |
| 30 | "executor/no-unknown-error-message": "error", | 30 | "executor/no-unknown-error-message": "error", |
| 31 | "executor/no-unsupported-effect-api": "error", | 31 | "executor/no-unsupported-effect-api": "error", |
| 32 | "executor/prefer-effect-predicate": "error", | ||
| 32 | "executor/prefer-schema-inferred-types": "error", | 33 | "executor/prefer-schema-inferred-types": "error", |
| 33 | "executor/prefer-value-inferred-extension-types": "error", | 34 | "executor/prefer-value-inferred-extension-types": "error", |
| 34 | "executor/prefer-yield-tagged-error": "error", | 35 | "executor/prefer-yield-tagged-error": "error", |
| @@ -0,0 +1,103 @@ | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Exit } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | CloudAuthApiTestContext, | ||
| 6 | CloudAuthApiTestContextLayer, | ||
| 7 | makeCloudAuthApiTestState, | ||
| 8 | } from "./cloud-auth-api.test-context"; | ||
| 9 | import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer"; | ||
| 10 | import { makeAutumnTestState } from "../services/autumn.test-layer"; | ||
| 11 | |||
| 12 | describe("create organization API", () => { | ||
| 13 | it.effect("lets a paid user create another organization through the HTTP API client", () => { | ||
| 14 | const state = makeCloudAuthApiTestState({ | ||
| 15 | workos: makeWorkOSTestState({ | ||
| 16 | memberships: [ | ||
| 17 | makeWorkOSTestMembership("org_free_1", "active"), | ||
| 18 | makeWorkOSTestMembership("org_free_2", "active"), | ||
| 19 | makeWorkOSTestMembership("org_paid", "active"), | ||
| 20 | ], | ||
| 21 | }), | ||
| 22 | autumn: makeAutumnTestState({ | ||
| 23 | subscriptionsByOrgId: { | ||
| 24 | org_paid: [{ planId: "team", status: "active" }], | ||
| 25 | }, | ||
| 26 | }), | ||
| 27 | }); | ||
| 28 | |||
| 29 | return Effect.gen(function* () { | ||
| 30 | const { client } = yield* CloudAuthApiTestContext; | ||
| 31 | |||
| 32 | const result = yield* client.cloudAuth.createOrganization({ | ||
| 33 | payload: { name: "Paid Extra Org" }, | ||
| 34 | }); | ||
| 35 | |||
| 36 | assert.deepEqual(result, { id: "org_created", name: "Paid Extra Org" }); | ||
| 37 | assert.deepEqual(state.workos.createdOrganizations, [ | ||
| 38 | { id: "org_created", name: "Paid Extra Org" }, | ||
| 39 | ]); | ||
| 40 | assert.deepEqual(state.workos.createdMemberships, [ | ||
| 41 | { organizationId: "org_created", userId: "user_1", roleSlug: "admin" }, | ||
| 42 | ]); | ||
| 43 | assert.deepEqual(state.userStore.upsertedOrganizations, [ | ||
| 44 | { id: "org_created", name: "Paid Extra Org" }, | ||
| 45 | ]); | ||
| 46 | }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it.effect( | ||
| 50 | "rejects a free-only user at the free organization limit through the HTTP API client", | ||
| 51 | () => { | ||
| 52 | const state = makeCloudAuthApiTestState({ | ||
| 53 | workos: makeWorkOSTestState({ | ||
| 54 | memberships: [ | ||
| 55 | makeWorkOSTestMembership("org_free_1", "active"), | ||
| 56 | makeWorkOSTestMembership("org_free_2", "active"), | ||
| 57 | makeWorkOSTestMembership("org_free_3", "active"), | ||
| 58 | ], | ||
| 59 | }), | ||
| 60 | }); | ||
| 61 | |||
| 62 | return Effect.gen(function* () { | ||
| 63 | const { client } = yield* CloudAuthApiTestContext; | ||
| 64 | |||
| 65 | const exit = yield* Effect.exit( | ||
| 66 | client.cloudAuth.createOrganization({ | ||
| 67 | payload: { name: "Blocked Org" }, | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | assert.isTrue(Exit.isFailure(exit)); | ||
| 72 | assert.deepEqual(state.workos.createdOrganizations, []); | ||
| 73 | assert.deepEqual(state.workos.createdMemberships, []); | ||
| 74 | assert.deepEqual(state.userStore.upsertedOrganizations, []); | ||
| 75 | }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); | ||
| 76 | }, | ||
| 77 | ); | ||
| 78 | |||
| 79 | it.effect("does not count pending memberships toward the free organization limit", () => { | ||
| 80 | const state = makeCloudAuthApiTestState({ | ||
| 81 | workos: makeWorkOSTestState({ | ||
| 82 | memberships: [ | ||
| 83 | makeWorkOSTestMembership("org_free_1", "active"), | ||
| 84 | makeWorkOSTestMembership("org_free_2", "active"), | ||
| 85 | makeWorkOSTestMembership("org_invited", "pending"), | ||
| 86 | ], | ||
| 87 | }), | ||
| 88 | }); | ||
| 89 | |||
| 90 | return Effect.gen(function* () { | ||
| 91 | const { client } = yield* CloudAuthApiTestContext; | ||
| 92 | |||
| 93 | const result = yield* client.cloudAuth.createOrganization({ | ||
| 94 | payload: { name: "Below Active Limit" }, | ||
| 95 | }); | ||
| 96 | |||
| 97 | assert.deepEqual(result, { id: "org_created", name: "Below Active Limit" }); | ||
| 98 | assert.deepEqual(state.workos.createdOrganizations, [ | ||
| 99 | { id: "org_created", name: "Below Active Limit" }, | ||
| 100 | ]); | ||
| 101 | }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); | ||
| 102 | }); | ||
| 103 | }); | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; | 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | import { HttpServerResponse } from "effect/unstable/http"; | 2 | import { HttpServerResponse } from "effect/unstable/http"; |
| 3 | import { Duration, Effect } from "effect"; | 3 | import { Duration, Effect, Predicate } from "effect"; |
| 4 | import { setCookie, deleteCookie } from "@tanstack/react-start/server"; | 4 | import { setCookie, deleteCookie } from "@tanstack/react-start/server"; |
| 5 | 5 | ||
| 6 | import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; | 6 | import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; |
| @@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors"; · 5 unmodified lines | |||
| 12 | import { WorkOSError } from "./errors"; | 12 | import { WorkOSError } from "./errors"; |
| 13 | import { WorkOSAuth } from "./workos"; | 13 | import { WorkOSAuth } from "./workos"; |
| 14 | import { ApiKeyService } from "./api-keys"; | 14 | import { ApiKeyService } from "./api-keys"; |
| 15 | import { AutumnService } from "../services/autumn"; | ||
| 16 | import { | ||
| 17 | hasPaidOrganizationSubscription, | ||
| 18 | isOverFreeOrganizationLimit, | ||
| 19 | shouldApplyFreeOrganizationLimit, | ||
| 20 | } from "./organization-limits"; | ||
| 15 | 21 | ||
| 16 | const COOKIE_OPTIONS = { | 22 | const COOKIE_OPTIONS = { |
| 17 | path: "/", | 23 | path: "/", |
| @@ -49,7 +55,6 @@ const DELETE_COOKIE_OPTIONS = { · 31 unmodified lines | |||
| 49 | secure: true, | 55 | secure: true, |
| 50 | }; | 56 | }; |
| 51 | 57 | ||
| 52 | const MAX_ORGANIZATIONS_PER_USER = 3; | ||
| 53 | const MAX_API_KEY_NAME_LENGTH = 80; | 58 | const MAX_API_KEY_NAME_LENGTH = 80; |
| 54 | 59 | ||
| 55 | const randomState = (): string => { | 60 | const randomState = (): string => { |
| @@ -232,9 +237,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 176 unmodified lines | |||
| 232 | ); | 237 | ); |
| 233 | 238 | ||
| 234 | return { | 239 | return { |
| 235 | organizations: organizations.filter( | 240 | organizations: organizations.filter(Predicate.isNotNull), |
| 236 | (org): org is NonNullable<typeof org> => org !== null, | ||
| 237 | ), | ||
| 238 | activeOrganizationId: session.organizationId, | 241 | activeOrganizationId: session.organizationId, |
| 239 | }; | 242 | }; |
| 240 | }), | 243 | }), |
| @@ -258,11 +261,38 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 17 unmodified lines | |||
| 258 | const workos = yield* WorkOSAuth; | 261 | const workos = yield* WorkOSAuth; |
| 259 | const users = yield* UserStoreService; | 262 | const users = yield* UserStoreService; |
| 260 | const session = yield* SessionContext; | 263 | const session = yield* SessionContext; |
| 264 | const autumn = yield* AutumnService; | ||
| 261 | 265 | ||
| 262 | const name = payload.name.trim(); | 266 | const name = payload.name.trim(); |
| 263 | const memberships = yield* workos.listUserMemberships(session.accountId); | 267 | const memberships = yield* workos.listUserMemberships(session.accountId); |
| 264 | if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) { | 268 | const activeMemberships = memberships.data.filter( |
| 265 | return yield* new WorkOSError(); | 269 | (membership) => membership.status === "active", |
| 270 | ); | ||
| 271 | |||
| 272 | if (isOverFreeOrganizationLimit(activeMemberships)) { | ||
| 273 | const paidOrganizationIds = yield* Effect.all( | ||
| 274 | activeMemberships.map((membership) => | ||
| 275 | autumn | ||
| 276 | .use((client) => | ||
| 277 | client.customers.getOrCreate({ customerId: membership.organizationId }), | ||
| 278 | ) | ||
| 279 | .pipe( | ||
| 280 | Effect.map((customer) => | ||
| 281 | hasPaidOrganizationSubscription(customer.subscriptions) | ||
| 282 | ? membership.organizationId | ||
| 283 | : null, | ||
| 284 | ), | ||
| 285 | ), | ||
| 286 | ), | ||
| 287 | { concurrency: 3 }, | ||
| 288 | ).pipe( | ||
| 289 | Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), | ||
| 290 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), | ||
| 291 | ); | ||
| 292 | |||
| 293 | if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) { | ||
| 294 | return yield* new WorkOSError(); | ||
| 295 | } | ||
| 266 | } | 296 | } |
| 267 | 297 | ||
| 268 | const org = yield* workos.createOrganization(name); | 298 | const org = yield* workos.createOrganization(name); |
| @@ -342,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 73 unmodified lines | |||
| 342 | ); | 372 | ); |
| 343 | 373 | ||
| 344 | return { | 374 | return { |
| 345 | invitations: enriched.filter((i): i is NonNullable<typeof i> => i !== null), | 375 | invitations: enriched.filter(Predicate.isNotNull), |
| 346 | }; | 376 | }; |
| 347 | }), | 377 | }), |
| 348 | ) | 378 | ) |
| @@ -0,0 +1,51 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | FREE_ORGANIZATIONS_PER_USER_LIMIT, | ||
| 5 | hasPaidOrganizationSubscription, | ||
| 6 | isOverFreeOrganizationLimit, | ||
| 7 | shouldApplyFreeOrganizationLimit, | ||
| 8 | } from "./organization-limits"; | ||
| 9 | |||
| 10 | describe("organization limits", () => { | ||
| 11 | it("treats active and trialing paid org subscriptions as paid", () => { | ||
| 12 | expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true); | ||
| 13 | expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("does not treat inactive paid plans or free plans as paid", () => { | ||
| 17 | expect(hasPaidOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(false); | ||
| 18 | expect(hasPaidOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false); | ||
| 19 | expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("applies the free org limit only when none of the user's active orgs are paid", () => { | ||
| 23 | const activeMemberships = [ | ||
| 24 | { organizationId: "org_free_1", status: "active" }, | ||
| 25 | { organizationId: "org_paid", status: "active" }, | ||
| 26 | ]; | ||
| 27 | |||
| 28 | expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set())).toBe(true); | ||
| 29 | expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set(["org_paid"]))).toBe(false); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("caps free-only users at active org memberships, not pending invitations", () => { | ||
| 33 | expect( | ||
| 34 | isOverFreeOrganizationLimit( | ||
| 35 | Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT - 1 }, (_, index) => ({ | ||
| 36 | organizationId: `org_${index}`, | ||
| 37 | status: "active", | ||
| 38 | })), | ||
| 39 | ), | ||
| 40 | ).toBe(false); | ||
| 41 | |||
| 42 | expect( | ||
| 43 | isOverFreeOrganizationLimit( | ||
| 44 | Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT }, (_, index) => ({ | ||
| 45 | organizationId: `org_${index}`, | ||
| 46 | status: "active", | ||
| 47 | })), | ||
| 48 | ), | ||
| 49 | ).toBe(true); | ||
| 50 | }); | ||
| 51 | }); | ||
| @@ -0,0 +1,37 @@ | |||
| 1 | import { | ||
| 2 | ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES, | ||
| 3 | PAID_AUTUMN_PLAN_IDS, | ||
| 4 | } from "../services/autumn-plans"; | ||
| 5 | |||
| 6 | export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3; | ||
| 7 | |||
| 8 | export type OrganizationLimitSubscriptionSummary = { | ||
| 9 | readonly planId?: string | null; | ||
| 10 | readonly status?: string | null; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export type OrganizationLimitMembershipSummary = { | ||
| 14 | readonly organizationId: string; | ||
| 15 | readonly status?: string | null; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export const isPaidOrganizationSubscription = ( | ||
| 19 | subscription: OrganizationLimitSubscriptionSummary, | ||
| 20 | ): boolean => | ||
| 21 | subscription.planId != null && | ||
| 22 | PAID_AUTUMN_PLAN_IDS.has(subscription.planId) && | ||
| 23 | ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""); | ||
| 24 | |||
| 25 | export const hasPaidOrganizationSubscription = ( | ||
| 26 | subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>, | ||
| 27 | ): boolean => subscriptions.some(isPaidOrganizationSubscription); | ||
| 28 | |||
| 29 | export const shouldApplyFreeOrganizationLimit = ( | ||
| 30 | activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>, | ||
| 31 | paidOrganizationIds: ReadonlySet<string>, | ||
| 32 | ): boolean => | ||
| 33 | !activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId)); | ||
| 34 | |||
| 35 | export const isOverFreeOrganizationLimit = ( | ||
| 36 | activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>, | ||
| 37 | ): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT; | ||
| @@ -1,3 +1,5 @@ | |||
| 1 | import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans"; | ||
| 2 | |||
| 1 | const MEMBER_LIMITS: Record<string, number | null> = { | 3 | const MEMBER_LIMITS: Record<string, number | null> = { |
| 2 | free: 3, | 4 | free: 3, |
| 3 | "free-pay-as-you-go": 3, | 5 | "free-pay-as-you-go": 3, |
| @@ -16,7 +18,7 @@ export const selectActiveMemberLimitPlan = ( · 12 unmodified lines | |||
| 16 | ): string => { | 18 | ): string => { |
| 17 | const active = | 19 | const active = |
| 18 | subscriptions.find((subscription) => | 20 | subscriptions.find((subscription) => |
| 19 | ["active", "trialing"].includes(subscription.status ?? ""), | 21 | ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), |
| 20 | ) ?? subscriptions[0]; | 22 | ) ?? subscriptions[0]; |
| 21 | return active?.planId ?? "free"; | 23 | return active?.planId ?? "free"; |
| 22 | }; | 24 | }; |
| @@ -0,0 +1,5 @@ | |||
| 1 | import { team } from "../../autumn.config"; | ||
| 2 | |||
| 3 | export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]); | ||
| 4 | |||
| 5 | export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp"; | 1 | import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp"; |
| 2 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 2 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | import { Data, Effect, Exit, Match, Option } from "effect"; | 3 | import { Data, Effect, Exit, Match, Option, Predicate } from "effect"; |
| 4 | 4 | ||
| 5 | export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{ | 5 | export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{ |
| 6 | readonly cause: unknown; | 6 | readonly cause: unknown; |
| @@ -100,7 +100,7 @@ export class JsonRpcRequestIdQueue { · 93 unmodified lines | |||
| 100 | const ids = [...new Set(await extractJsonRpcRequestIdKeys(request))]; | 100 | const ids = [...new Set(await extractJsonRpcRequestIdKeys(request))]; |
| 101 | if (ids.length === 0) return await run(); | 101 | if (ids.length === 0) return await run(); |
| 102 | 102 | ||
| 103 | const previous = ids.map((id) => this.inFlight.get(id)).filter((p) => p !== undefined); | 103 | const previous = ids.map((id) => this.inFlight.get(id)).filter(Predicate.isNotUndefined); |
| 104 | let release!: () => void; | 104 | let release!: () => void; |
| 105 | const current = new Promise<void>((resolve) => { | 105 | const current = new Promise<void>((resolve) => { |
| 106 | release = resolve; | 106 | release = resolve; |
| @@ -404,7 +404,7 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 403 unmodified lines | |||
| 404 | const ranked = all | 404 | const ranked = all |
| 405 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) | 405 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) |
| 406 | .map((tool: Tool) => scoreToolMatch(tool, query)) | 406 | .map((tool: Tool) => scoreToolMatch(tool, query)) |
| 407 | .filter((tool): tool is ToolDiscoveryResult => tool !== null) | 407 | .filter(Predicate.isNotNull) |
| 408 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); | 408 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); |
| 409 | 409 | ||
| 410 | const page = paginate(ranked, offset, limit); | 410 | const page = paginate(ranked, offset, limit); |
| @@ -139,4 +139,46 @@ describe("executor oxlint plugin", () => { · 138 unmodified lines | |||
| 139 | expect(result.stdout).toContain("Found 0 warnings and 0 errors."); | 139 | expect(result.stdout).toContain("Found 0 warnings and 0 errors."); |
| 140 | expect(result.stderr).toBe(""); | 140 | expect(result.stderr).toBe(""); |
| 141 | }); | 141 | }); |
| 142 | |||
| 143 | it("rejects hand-rolled null predicates in Effect files", async () => { | ||
| 144 | const result = await runOxlintOn( | ||
| 145 | "manual-null-predicate.ts", | ||
| 146 | ` | ||
| 147 | import { Effect } from "effect"; | ||
| 148 | |||
| 149 | const isNonNull = <A>(value: A | null): value is A => value !== null; | ||
| 150 | |||
| 151 | export const values = [Effect.void, null].filter(isNonNull); | ||
| 152 | `, | ||
| 153 | ); | ||
| 154 | |||
| 155 | expect(result.status).toBe(1); | ||
| 156 | expect(result.stdout).toContain("executor(prefer-effect-predicate)"); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("rejects inline nullish filter predicates in Effect files", async () => { | ||
| 160 | const result = await runOxlintOn( | ||
| 161 | "inline-nullish-filter.ts", | ||
| 162 | ` | ||
| 163 | import { Predicate } from "effect"; | ||
| 164 | |||
| 165 | export const values = ["ok", null].filter((value): value is string => value !== null); | ||
| 166 | `, | ||
| 167 | ); | ||
| 168 | |||
| 169 | expect(result.status).toBe(1); | ||
| 170 | expect(result.stdout).toContain("executor(prefer-effect-predicate)"); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("allows null filters in files that do not import Effect", async () => { | ||
| 174 | const result = await runOxlintOn( | ||
| 175 | "plain-null-filter.ts", | ||
| 176 | ` | ||
| 177 | export const values = ["ok", null].filter((value): value is string => value !== null); | ||
| 178 | `, | ||
| 179 | ); | ||
| 180 | |||
| 181 | expect(result.status).toBe(0); | ||
| 182 | expect(result.stdout).toContain("Found 0 warnings and 0 errors."); | ||
| 183 | }); | ||
| 142 | }); | 184 | }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Effect, Option } from "effect"; | 1 | import { Effect, Option, Predicate } from "effect"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { parse, resolveSpecText, type ParsedDocument } from "./parse"; | 4 | import { parse, resolveSpecText, type ParsedDocument } from "./parse"; |
| @@ -255,7 +255,7 @@ const buildHeaderPresets = ( · 250 unmodified lines | |||
| 255 | return strategies.flatMap((strategy) => { | 255 | return strategies.flatMap((strategy) => { |
| 256 | const resolved = strategy.schemes | 256 | const resolved = strategy.schemes |
| 257 | .map((name) => schemeMap.get(name)) | 257 | .map((name) => schemeMap.get(name)) |
| 258 | .filter((s): s is SecurityScheme => s !== undefined); | 258 | .filter(Predicate.isNotUndefined); |
| 259 | 259 | ||
| 260 | if (resolved.length === 0) return []; | 260 | if (resolved.length === 0) return []; |
| 261 | 261 | ||
| @@ -27,6 +27,7 @@ import noUnknownErrorMessage from "./oxlint-plugin-executor/rules/no-unknown-err · 26 unmodified lines | |||
| 27 | import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; | 27 | import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; |
| 28 | import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js"; | 28 | import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js"; |
| 29 | import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; | 29 | import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; |
| 30 | import preferEffectPredicate from "./oxlint-plugin-executor/rules/prefer-effect-predicate.js"; | ||
| 30 | import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js"; | 31 | import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js"; |
| 31 | import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js"; | 32 | import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js"; |
| 32 | import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js"; | 33 | import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js"; |
| @@ -67,6 +68,7 @@ export default { · 34 unmodified lines | |||
| 67 | "no-unknown-error-message": noUnknownErrorMessage, | 68 | "no-unknown-error-message": noUnknownErrorMessage, |
| 68 | "no-unknown-shape-probing": noUnknownShapeProbing, | 69 | "no-unknown-shape-probing": noUnknownShapeProbing, |
| 69 | "no-unsupported-effect-api": noUnsupportedEffectApi, | 70 | "no-unsupported-effect-api": noUnsupportedEffectApi, |
| 71 | "prefer-effect-predicate": preferEffectPredicate, | ||
| 70 | "prefer-schema-inferred-types": preferSchemaInferredTypes, | 72 | "prefer-schema-inferred-types": preferSchemaInferredTypes, |
| 71 | "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes, | 73 | "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes, |
| 72 | "prefer-yield-tagged-error": preferYieldTaggedError, | 74 | "prefer-yield-tagged-error": preferYieldTaggedError, |
| @@ -0,0 +1,89 @@ | |||
| 1 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Use Predicate.isNotNull/isNotUndefined/isNotNullish from effect instead of hand-rolled nullish predicates."; | ||
| 5 | |||
| 6 | const nullishOperators = new Set(["!==", "!=", "===", "=="]); | ||
| 7 | |||
| 8 | const isNullLiteral = (node) => node?.type === "Literal" && node.value === null; | ||
| 9 | const isUndefinedIdentifier = (node) => isIdentifier(node, "undefined"); | ||
| 10 | const isNullishLiteral = (node) => isNullLiteral(node) || isUndefinedIdentifier(node); | ||
| 11 | |||
| 12 | const isNullishComparison = (node, identifierName) => { | ||
| 13 | const expression = unwrapExpression(node); | ||
| 14 | if (expression?.type !== "BinaryExpression" || !nullishOperators.has(expression.operator)) { | ||
| 15 | return false; | ||
| 16 | } | ||
| 17 | |||
| 18 | const left = unwrapExpression(expression.left); | ||
| 19 | const right = unwrapExpression(expression.right); | ||
| 20 | return ( | ||
| 21 | (isIdentifier(left, identifierName) && isNullishLiteral(right)) || | ||
| 22 | (isIdentifier(right, identifierName) && isNullishLiteral(left)) | ||
| 23 | ); | ||
| 24 | }; | ||
| 25 | |||
| 26 | const getSingleIdentifierParamName = (params) => { | ||
| 27 | if (params.length !== 1) return undefined; | ||
| 28 | const param = unwrapExpression(params[0]); | ||
| 29 | return param?.type === "Identifier" ? param.name : undefined; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const isNullishPredicateFunction = (node) => { | ||
| 33 | const name = getSingleIdentifierParamName(node.params ?? []); | ||
| 34 | return name !== undefined && isNullishComparison(node.body, name); | ||
| 35 | }; | ||
| 36 | |||
| 37 | const isFilterCall = (node) => { | ||
| 38 | const callee = unwrapExpression(node.callee); | ||
| 39 | return callee?.type === "MemberExpression" && getPropertyName(callee.property) === "filter"; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const importsEffect = (node) => { | ||
| 43 | const source = node.source; | ||
| 44 | return source?.type === "Literal" && source.value === "effect"; | ||
| 45 | }; | ||
| 46 | |||
| 47 | export default { | ||
| 48 | meta: { | ||
| 49 | type: "problem", | ||
| 50 | docs: { | ||
| 51 | description: message, | ||
| 52 | }, | ||
| 53 | }, | ||
| 54 | create(context) { | ||
| 55 | let hasEffectImport = false; | ||
| 56 | |||
| 57 | return { | ||
| 58 | ImportDeclaration(node) { | ||
| 59 | if (importsEffect(node)) { | ||
| 60 | hasEffectImport = true; | ||
| 61 | } | ||
| 62 | }, | ||
| 63 | VariableDeclarator(node) { | ||
| 64 | if (!hasEffectImport) return; | ||
| 65 | const init = unwrapExpression(node.init); | ||
| 66 | if (init?.type === "ArrowFunctionExpression" && isNullishPredicateFunction(init)) { | ||
| 67 | context.report({ node: init, message }); | ||
| 68 | } | ||
| 69 | }, | ||
| 70 | FunctionDeclaration(node) { | ||
| 71 | if (!hasEffectImport) return; | ||
| 72 | if (isNullishPredicateFunction(node)) { | ||
| 73 | context.report({ node, message }); | ||
| 74 | } | ||
| 75 | }, | ||
| 76 | CallExpression(node) { | ||
| 77 | if (!hasEffectImport || !isFilterCall(node)) return; | ||
| 78 | const predicate = unwrapExpression(node.arguments[0]); | ||
| 79 | if ( | ||
| 80 | (predicate?.type === "ArrowFunctionExpression" || | ||
| 81 | predicate?.type === "FunctionExpression") && | ||
| 82 | isNullishPredicateFunction(predicate) | ||
| 83 | ) { | ||
| 84 | context.report({ node: predicate, message }); | ||
| 85 | } | ||
| 86 | }, | ||
| 87 | }; | ||
| 88 | }, | ||
| 89 | }; | ||