* 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 | 79 | parents, tracked PR numbers, and PR titles when GitHub is available. It is |
| 80 | 80 | opinionated: backup branches are hidden, and when the current branch is |
| 81 | 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 | 89 | Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base |
| 84 | 90 | inference before mutation. |
| @@ -129,6 +135,9 @@ nonzero if any stack failed. · 44 unmodified lines | ||
| 129 | 135 | Sync output is intentionally outcome-oriented. It should show the stack tree with |
| 130 | 136 | icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It |
| 131 | 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 | 142 | If a replay fails, `stack sync` aborts the failed cherry-pick, restores the |
| 134 | 143 | original branch, deletes the temporary replay branch, keeps backups and the undo |
| @@ -29,6 +29,7 @@ · 28 unmodified lines | ||
| 29 | 29 | "executor/no-try-catch-or-throw": "error", |
| 30 | 30 | "executor/no-unknown-error-message": "error", |
| 31 | 31 | "executor/no-unsupported-effect-api": "error", |
| 32 | + "executor/prefer-effect-predicate": "error", | |
| 32 | 33 | "executor/prefer-schema-inferred-types": "error", |
| 33 | 34 | "executor/prefer-value-inferred-extension-types": "error", |
| 34 | 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 | 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | 2 | import { HttpServerResponse } from "effect/unstable/http"; |
| 3 | −import { Duration, Effect } from "effect"; | |
| 3 | +import { Duration, Effect, Predicate } from "effect"; | |
| 4 | 4 | import { setCookie, deleteCookie } from "@tanstack/react-start/server"; |
| 5 | 5 | |
| 6 | 6 | import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; |
| @@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors"; · 5 unmodified lines | ||
| 12 | 12 | import { WorkOSError } from "./errors"; |
| 13 | 13 | import { WorkOSAuth } from "./workos"; |
| 14 | 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 | 22 | const COOKIE_OPTIONS = { |
| 17 | 23 | path: "/", |
| @@ -49,7 +55,6 @@ const DELETE_COOKIE_OPTIONS = { · 31 unmodified lines | ||
| 49 | 55 | secure: true, |
| 50 | 56 | }; |
| 51 | 57 | |
| 52 | −const MAX_ORGANIZATIONS_PER_USER = 3; | |
| 53 | 58 | const MAX_API_KEY_NAME_LENGTH = 80; |
| 54 | 59 | |
| 55 | 60 | const randomState = (): string => { |
| @@ -232,9 +237,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 176 unmodified lines | ||
| 232 | 237 | ); |
| 233 | 238 | |
| 234 | 239 | return { |
| 235 | − organizations: organizations.filter( | |
| 236 | − (org): org is NonNullable<typeof org> => org !== null, | |
| 237 | − ), | |
| 240 | + organizations: organizations.filter(Predicate.isNotNull), | |
| 238 | 241 | activeOrganizationId: session.organizationId, |
| 239 | 242 | }; |
| 240 | 243 | }), |
| @@ -258,11 +261,38 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 17 unmodified lines | ||
| 258 | 261 | const workos = yield* WorkOSAuth; |
| 259 | 262 | const users = yield* UserStoreService; |
| 260 | 263 | const session = yield* SessionContext; |
| 264 | + const autumn = yield* AutumnService; | |
| 261 | 265 | |
| 262 | 266 | const name = payload.name.trim(); |
| 263 | 267 | const memberships = yield* workos.listUserMemberships(session.accountId); |
| 264 | − if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) { | |
| 265 | − return yield* new WorkOSError(); | |
| 268 | + const activeMemberships = memberships.data.filter( | |
| 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 | 298 | const org = yield* workos.createOrganization(name); |
| @@ -342,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 73 unmodified lines | ||
| 342 | 372 | ); |
| 343 | 373 | |
| 344 | 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 | 3 | const MEMBER_LIMITS: Record<string, number | null> = { |
| 2 | 4 | free: 3, |
| 3 | 5 | "free-pay-as-you-go": 3, |
| @@ -16,7 +18,7 @@ export const selectActiveMemberLimitPlan = ( · 12 unmodified lines | ||
| 16 | 18 | ): string => { |
| 17 | 19 | const active = |
| 18 | 20 | subscriptions.find((subscription) => |
| 19 | − ["active", "trialing"].includes(subscription.status ?? ""), | |
| 21 | + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), | |
| 20 | 22 | ) ?? subscriptions[0]; |
| 21 | 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 | 1 | import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp"; |
| 2 | 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 | 5 | export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{ |
| 6 | 6 | readonly cause: unknown; |
| @@ -100,7 +100,7 @@ export class JsonRpcRequestIdQueue { · 93 unmodified lines | ||
| 100 | 100 | const ids = [...new Set(await extractJsonRpcRequestIdKeys(request))]; |
| 101 | 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 | 104 | let release!: () => void; |
| 105 | 105 | const current = new Promise<void>((resolve) => { |
| 106 | 106 | release = resolve; |
| @@ -404,7 +404,7 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 403 unmodified lines | ||
| 404 | 404 | const ranked = all |
| 405 | 405 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) |
| 406 | 406 | .map((tool: Tool) => scoreToolMatch(tool, query)) |
| 407 | − .filter((tool): tool is ToolDiscoveryResult => tool !== null) | |
| 407 | + .filter(Predicate.isNotNull) | |
| 408 | 408 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); |
| 409 | 409 | |
| 410 | 410 | const page = paginate(ranked, offset, limit); |
| @@ -139,4 +139,46 @@ describe("executor oxlint plugin", () => { · 138 unmodified lines | ||
| 139 | 139 | expect(result.stdout).toContain("Found 0 warnings and 0 errors."); |
| 140 | 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 | 2 | import { Schema } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { parse, resolveSpecText, type ParsedDocument } from "./parse"; |
| @@ -255,7 +255,7 @@ const buildHeaderPresets = ( · 250 unmodified lines | ||
| 255 | 255 | return strategies.flatMap((strategy) => { |
| 256 | 256 | const resolved = strategy.schemes |
| 257 | 257 | .map((name) => schemeMap.get(name)) |
| 258 | − .filter((s): s is SecurityScheme => s !== undefined); | |
| 258 | + .filter(Predicate.isNotUndefined); | |
| 259 | 259 | |
| 260 | 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 | 27 | import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; |
| 28 | 28 | import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js"; |
| 29 | 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 | 31 | import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js"; |
| 31 | 32 | import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js"; |
| 32 | 33 | import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js"; |
| @@ -67,6 +68,7 @@ export default { · 34 unmodified lines | ||
| 67 | 68 | "no-unknown-error-message": noUnknownErrorMessage, |
| 68 | 69 | "no-unknown-shape-probing": noUnknownShapeProbing, |
| 69 | 70 | "no-unsupported-effect-api": noUnsupportedEffectApi, |
| 71 | + "prefer-effect-predicate": preferEffectPredicate, | |
| 70 | 72 | "prefer-schema-inferred-types": preferSchemaInferredTypes, |
| 71 | 73 | "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes, |
| 72 | 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 | +}; | |