History

[codex] Fix paid organization creation limit (#829)

* Fix paid organization creation limit * Clarify stack PR URL reporting * Clarify clickable stack PR links

Rhys Sullivan committed 5 months ago

Commit 766c7901 parent 62b8b7d14 files · +385 −14Browse files

Unified · Split

14 files
  1. .agents/skills/stack/SKILL.md +9 −0
  2. .oxlintrc.jsonc +1 −0
  3. apps/cloud/src/auth/create-organization.e2e.node.test.ts +103 −0
  4. apps/cloud/src/auth/handlers.ts +38 −8
  5. apps/cloud/src/auth/organization-limits.node.test.ts +51 −0
  6. apps/cloud/src/auth/organization-limits.ts +37 −0
  7. apps/cloud/src/org/member-limits.ts +3 −1
  8. apps/cloud/src/services/autumn-plans.ts +5 −0
  9. apps/cloud/src/services/mcp-worker-transport.ts +2 −2
  10. packages/core/execution/src/tool-invoker.ts +1 −1
  11. packages/core/sdk/src/oxlint-plugin-executor.test.ts +42 −0
  12. packages/plugins/openapi/src/sdk/preview.ts +2 −2
  13. scripts/oxlint-plugin-executor.js +2 −0
  14. scripts/oxlint-plugin-executor/rules/prefer-effect-predicate.js +89 −0
.agents/skills/stack/SKILL.md+9−0
@@ -79,6 +79,12 @@ Use this to understand local stack metadata, current branch position, missing · 78 unmodified lines
79parents, tracked PR numbers, and PR titles when GitHub is available. It is79parents, tracked PR numbers, and PR titles when GitHub is available. It is
80opinionated: backup branches are hidden, and when the current branch is80opinionated: backup branches are hidden, and when the current branch is
81stack-relevant it focuses on that stack instead of listing every local branch.81stack-relevant it focuses on that stack instead of listing every local branch.
82When relaying a stack view to the user, always include the full PR URLs for
83every branch that has a PR, not just PR numbers or titles. If `stack status`
84does not print a URL for a branch, query GitHub with `gh pr view`/`gh pr list`
85and include the URL in the shown stack view. Use Markdown links or plain URL
86lines for user-facing stack views so the PR links are clickable; do not hide the
87only copy of the PR URLs inside a fenced code block.
82 88
83Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base89Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base
84inference before mutation.90inference before mutation.
@@ -129,6 +135,9 @@ nonzero if any stack failed. · 44 unmodified lines
129Sync output is intentionally outcome-oriented. It should show the stack tree with135Sync output is intentionally outcome-oriented. It should show the stack tree with
130icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It136icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It
131should not default to internal phase logs like fetch, inspect, or reconcile.137should not default to internal phase logs like fetch, inspect, or reconcile.
138When you show a stack tree to the user, include full PR URLs alongside each PR
139entry so the stack view is directly actionable. Prefer Markdown list/tree output
140with clickable links over fenced code blocks when relaying PR URLs to a user.
132 141
133If a replay fails, `stack sync` aborts the failed cherry-pick, restores the142If a replay fails, `stack sync` aborts the failed cherry-pick, restores the
134original branch, deletes the temporary replay branch, keeps backups and the undo143original branch, deletes the temporary replay branch, keeps backups and the undo
.oxlintrc.jsonc+1−0
@@ -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",
apps/cloud/src/auth/create-organization.e2e.node.test.ts+103−0
@@ -0,0 +1,103 @@
1import { assert, describe, it } from "@effect/vitest";
2import { Effect, Exit } from "effect";
3
4import {
5 CloudAuthApiTestContext,
6 CloudAuthApiTestContextLayer,
7 makeCloudAuthApiTestState,
8} from "./cloud-auth-api.test-context";
9import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer";
10import { makeAutumnTestState } from "../services/autumn.test-layer";
11
12describe("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});
apps/cloud/src/auth/handlers.ts+38−8
@@ -1,6 +1,6 @@
1import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";1import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpServerResponse } from "effect/unstable/http";2import { HttpServerResponse } from "effect/unstable/http";
3import { Duration, Effect } from "effect";3import { Duration, Effect, Predicate } from "effect";
4import { setCookie, deleteCookie } from "@tanstack/react-start/server";4import { setCookie, deleteCookie } from "@tanstack/react-start/server";
5 5
6import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";6import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
@@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors"; · 5 unmodified lines
12import { WorkOSError } from "./errors";12import { WorkOSError } from "./errors";
13import { WorkOSAuth } from "./workos";13import { WorkOSAuth } from "./workos";
14import { ApiKeyService } from "./api-keys";14import { ApiKeyService } from "./api-keys";
15import { AutumnService } from "../services/autumn";
16import {
17 hasPaidOrganizationSubscription,
18 isOverFreeOrganizationLimit,
19 shouldApplyFreeOrganizationLimit,
20} from "./organization-limits";
15 21
16const COOKIE_OPTIONS = {22const 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
52const MAX_ORGANIZATIONS_PER_USER = 3;
53const MAX_API_KEY_NAME_LENGTH = 80;58const MAX_API_KEY_NAME_LENGTH = 80;
54 59
55const randomState = (): string => {60const 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 )
apps/cloud/src/auth/organization-limits.node.test.ts+51−0
@@ -0,0 +1,51 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 FREE_ORGANIZATIONS_PER_USER_LIMIT,
5 hasPaidOrganizationSubscription,
6 isOverFreeOrganizationLimit,
7 shouldApplyFreeOrganizationLimit,
8} from "./organization-limits";
9
10describe("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});
apps/cloud/src/auth/organization-limits.ts+37−0
@@ -0,0 +1,37 @@
1import {
2 ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES,
3 PAID_AUTUMN_PLAN_IDS,
4} from "../services/autumn-plans";
5
6export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3;
7
8export type OrganizationLimitSubscriptionSummary = {
9 readonly planId?: string | null;
10 readonly status?: string | null;
11};
12
13export type OrganizationLimitMembershipSummary = {
14 readonly organizationId: string;
15 readonly status?: string | null;
16};
17
18export 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
25export const hasPaidOrganizationSubscription = (
26 subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
27): boolean => subscriptions.some(isPaidOrganizationSubscription);
28
29export const shouldApplyFreeOrganizationLimit = (
30 activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
31 paidOrganizationIds: ReadonlySet<string>,
32): boolean =>
33 !activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId));
34
35export const isOverFreeOrganizationLimit = (
36 activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
37): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT;
apps/cloud/src/org/member-limits.ts+3−1
@@ -1,3 +1,5 @@
1import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans";
2
1const MEMBER_LIMITS: Record<string, number | null> = {3const 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};
apps/cloud/src/services/autumn-plans.ts+5−0
@@ -0,0 +1,5 @@
1import { team } from "../../autumn.config";
2
3export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]);
4
5export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
apps/cloud/src/services/mcp-worker-transport.ts+2−2
@@ -1,6 +1,6 @@
1import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";1import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";
2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { Data, Effect, Exit, Match, Option } from "effect";3import { Data, Effect, Exit, Match, Option, Predicate } from "effect";
4 4
5export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{5export 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;
packages/core/execution/src/tool-invoker.ts+1−1
@@ -404,7 +404,7 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 403 unmodified lines
404 const ranked = all404 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);
packages/core/sdk/src/oxlint-plugin-executor.test.ts+42−0
@@ -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});
packages/plugins/openapi/src/sdk/preview.ts+2−2
@@ -1,4 +1,4 @@
1import { Effect, Option } from "effect";1import { Effect, Option, Predicate } from "effect";
2import { Schema } from "effect";2import { Schema } from "effect";
3 3
4import { parse, resolveSpecText, type ParsedDocument } from "./parse";4import { 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.schemes256 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
scripts/oxlint-plugin-executor.js+2−0
@@ -27,6 +27,7 @@ import noUnknownErrorMessage from "./oxlint-plugin-executor/rules/no-unknown-err · 26 unmodified lines
27import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";27import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";
28import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js";28import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js";
29import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js";29import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js";
30import preferEffectPredicate from "./oxlint-plugin-executor/rules/prefer-effect-predicate.js";
30import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js";31import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js";
31import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js";32import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js";
32import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js";33import 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,
scripts/oxlint-plugin-executor/rules/prefer-effect-predicate.js+89−0
@@ -0,0 +1,89 @@
1import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js";
2
3const message =
4 "Use Predicate.isNotNull/isNotUndefined/isNotNullish from effect instead of hand-rolled nullish predicates.";
5
6const nullishOperators = new Set(["!==", "!=", "===", "=="]);
7
8const isNullLiteral = (node) => node?.type === "Literal" && node.value === null;
9const isUndefinedIdentifier = (node) => isIdentifier(node, "undefined");
10const isNullishLiteral = (node) => isNullLiteral(node) || isUndefinedIdentifier(node);
11
12const 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
26const 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
32const isNullishPredicateFunction = (node) => {
33 const name = getSingleIdentifierParamName(node.params ?? []);
34 return name !== undefined && isNullishComparison(node.body, name);
35};
36
37const isFilterCall = (node) => {
38 const callee = unwrapExpression(node.callee);
39 return callee?.type === "MemberExpression" && getPropertyName(callee.property) === "filter";
40};
41
42const importsEffect = (node) => {
43 const source = node.source;
44 return source?.type === "Literal" && source.value === "effect";
45};
46
47export 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};