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
7979 parents, tracked PR numbers, and PR titles when GitHub is available. It is
8080 opinionated: backup branches are hidden, and when the current branch is
8181 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.
8288
8389 Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base
8490 inference before mutation.
@@ -129,6 +135,9 @@ nonzero if any stack failed. · 44 unmodified lines
129135 Sync output is intentionally outcome-oriented. It should show the stack tree with
130136 icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It
131137 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.
132141
133142 If a replay fails, `stack sync` aborts the failed cherry-pick, restores the
134143 original branch, deletes the temporary replay branch, keeps backups and the undo
.oxlintrc.jsonc+1−0
@@ -29,6 +29,7 @@ · 28 unmodified lines
2929 "executor/no-try-catch-or-throw": "error",
3030 "executor/no-unknown-error-message": "error",
3131 "executor/no-unsupported-effect-api": "error",
32+ "executor/prefer-effect-predicate": "error",
3233 "executor/prefer-schema-inferred-types": "error",
3334 "executor/prefer-value-inferred-extension-types": "error",
3435 "executor/prefer-yield-tagged-error": "error",
apps/cloud/src/auth/create-organization.e2e.node.test.ts+103−0
@@ -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+});
apps/cloud/src/auth/handlers.ts+38−8
@@ -1,6 +1,6 @@
11 import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
22 import { HttpServerResponse } from "effect/unstable/http";
3−import { Duration, Effect } from "effect";
3+import { Duration, Effect, Predicate } from "effect";
44 import { setCookie, deleteCookie } from "@tanstack/react-start/server";
55
66 import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
@@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors"; · 5 unmodified lines
1212 import { WorkOSError } from "./errors";
1313 import { WorkOSAuth } from "./workos";
1414 import { ApiKeyService } from "./api-keys";
15+import { AutumnService } from "../services/autumn";
16+import {
17+ hasPaidOrganizationSubscription,
18+ isOverFreeOrganizationLimit,
19+ shouldApplyFreeOrganizationLimit,
20+} from "./organization-limits";
1521
1622 const COOKIE_OPTIONS = {
1723 path: "/",
@@ -49,7 +55,6 @@ const DELETE_COOKIE_OPTIONS = { · 31 unmodified lines
4955 secure: true,
5056 };
5157
52−const MAX_ORGANIZATIONS_PER_USER = 3;
5358 const MAX_API_KEY_NAME_LENGTH = 80;
5459
5560 const randomState = (): string => {
@@ -232,9 +237,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 176 unmodified lines
232237 );
233238
234239 return {
235− organizations: organizations.filter(
236− (org): org is NonNullable<typeof org> => org !== null,
237− ),
240+ organizations: organizations.filter(Predicate.isNotNull),
238241 activeOrganizationId: session.organizationId,
239242 };
240243 }),
@@ -258,11 +261,38 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 17 unmodified lines
258261 const workos = yield* WorkOSAuth;
259262 const users = yield* UserStoreService;
260263 const session = yield* SessionContext;
264+ const autumn = yield* AutumnService;
261265
262266 const name = payload.name.trim();
263267 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+ }
266296 }
267297
268298 const org = yield* workos.createOrganization(name);
@@ -342,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 73 unmodified lines
342372 );
343373
344374 return {
345− invitations: enriched.filter((i): i is NonNullable<typeof i> => i !== null),
375+ invitations: enriched.filter(Predicate.isNotNull),
346376 };
347377 }),
348378 )
apps/cloud/src/auth/organization-limits.node.test.ts+51−0
@@ -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+});
apps/cloud/src/auth/organization-limits.ts+37−0
@@ -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;
apps/cloud/src/org/member-limits.ts+3−1
@@ -1,3 +1,5 @@
1+import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans";
2+
13 const MEMBER_LIMITS: Record<string, number | null> = {
24 free: 3,
35 "free-pay-as-you-go": 3,
@@ -16,7 +18,7 @@ export const selectActiveMemberLimitPlan = ( · 12 unmodified lines
1618 ): string => {
1719 const active =
1820 subscriptions.find((subscription) =>
19− ["active", "trialing"].includes(subscription.status ?? ""),
21+ ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""),
2022 ) ?? subscriptions[0];
2123 return active?.planId ?? "free";
2224 };
apps/cloud/src/services/autumn-plans.ts+5−0
@@ -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"]);
apps/cloud/src/services/mcp-worker-transport.ts+2−2
@@ -1,6 +1,6 @@
11 import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";
22 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";
44
55 export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{
66 readonly cause: unknown;
@@ -100,7 +100,7 @@ export class JsonRpcRequestIdQueue { · 93 unmodified lines
100100 const ids = [...new Set(await extractJsonRpcRequestIdKeys(request))];
101101 if (ids.length === 0) return await run();
102102
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);
104104 let release!: () => void;
105105 const current = new Promise<void>((resolve) => {
106106 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
404404 const ranked = all
405405 .filter((tool: Tool) => matchesNamespace(tool, options?.namespace))
406406 .map((tool: Tool) => scoreToolMatch(tool, query))
407− .filter((tool): tool is ToolDiscoveryResult => tool !== null)
407+ .filter(Predicate.isNotNull)
408408 .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
409409
410410 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
139139 expect(result.stdout).toContain("Found 0 warnings and 0 errors.");
140140 expect(result.stderr).toBe("");
141141 });
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+ });
142184 });
packages/plugins/openapi/src/sdk/preview.ts+2−2
@@ -1,4 +1,4 @@
1−import { Effect, Option } from "effect";
1+import { Effect, Option, Predicate } from "effect";
22 import { Schema } from "effect";
33
44 import { parse, resolveSpecText, type ParsedDocument } from "./parse";
@@ -255,7 +255,7 @@ const buildHeaderPresets = ( · 250 unmodified lines
255255 return strategies.flatMap((strategy) => {
256256 const resolved = strategy.schemes
257257 .map((name) => schemeMap.get(name))
258− .filter((s): s is SecurityScheme => s !== undefined);
258+ .filter(Predicate.isNotUndefined);
259259
260260 if (resolved.length === 0) return [];
261261
scripts/oxlint-plugin-executor.js+2−0
@@ -27,6 +27,7 @@ import noUnknownErrorMessage from "./oxlint-plugin-executor/rules/no-unknown-err · 26 unmodified lines
2727 import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";
2828 import noUnsupportedEffectApi from "./oxlint-plugin-executor/rules/no-unsupported-effect-api.js";
2929 import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js";
30+import preferEffectPredicate from "./oxlint-plugin-executor/rules/prefer-effect-predicate.js";
3031 import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js";
3132 import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js";
3233 import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js";
@@ -67,6 +68,7 @@ export default { · 34 unmodified lines
6768 "no-unknown-error-message": noUnknownErrorMessage,
6869 "no-unknown-shape-probing": noUnknownShapeProbing,
6970 "no-unsupported-effect-api": noUnsupportedEffectApi,
71+ "prefer-effect-predicate": preferEffectPredicate,
7072 "prefer-schema-inferred-types": preferSchemaInferredTypes,
7173 "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes,
7274 "prefer-yield-tagged-error": preferYieldTaggedError,
scripts/oxlint-plugin-executor/rules/prefer-effect-predicate.js+89−0
@@ -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+};