authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 19:40:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 19:40:26-07:00
log194f838e7636f97739c440c7855aed195dbd7f52
tree636e1e0ee32d20178fb127362450ef5e0e2a9f0a
parentec44bc56f598be16ffbc22ed0e4e095447043844
signature Signed by PGP key B5690EEEBB952194

chore: audit lint directives and move plugin allowlists into config (#9300)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

21 files changed, 258 insertions(+), 231 deletions(-)

apps/desktop/src/preview-pip-preload.ts-1
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime.
2import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts";1import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts";
3import { contextBridge, ipcRenderer } from "electron";2import { contextBridge, ipcRenderer } from "electron";
43
apps/mobile/metro.config.js+1-2
...@@ -36,8 +36,7 @@ config.resolver = {...@@ -36,8 +36,7 @@ config.resolver = {
36 new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`),36 new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`),
37 ],37 ],
38 extraNodeModules: {38 extraNodeModules: {
39 // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread39 ...config.resolver?.extraNodeModules,
40 ...(config.resolver?.extraNodeModules ?? {}),
41 shiki: mobileShikiRoot,40 shiki: mobileShikiRoot,
42 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),41 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
43 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),42 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+10-8
...@@ -9,10 +9,10 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([...@@ -9,10 +9,10 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
9 StyleSheet.create({}),9 StyleSheet.create({}),
10]);10]);
1111
12const textDefaults: TextProps = {12const textDefaults = {
13 allowFontScaling: true,13 allowFontScaling: true,
14 selectable: true,14 selectable: true,
15};15} satisfies TextProps;
1616
17const useTextAncestorContext = () => React.useContext(TextAncestorContext);17const useTextAncestorContext = () => React.useContext(TextAncestorContext);
1818
...@@ -28,7 +28,11 @@ export type ContextMenuActionEvent = {...@@ -28,7 +28,11 @@ export type ContextMenuActionEvent = {
28 nativeEvent: { target: number; actionIdentifier: string };28 nativeEvent: { target: number; actionIdentifier: string };
29};29};
3030
31export type MarkdownTextPrimitiveProps = TextProps & {31/**
32 * `onTextLayout` is not offered: the native view reports plain line strings
33 * while the React Native Text fallback reports measured `TextLayoutLine`s.
34 */
35export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
32 uiTextView?: boolean;36 uiTextView?: boolean;
33 contextMenuConfig?: string;37 contextMenuConfig?: string;
34 onContextMenuAction?: (event: ContextMenuActionEvent) => void;38 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
...@@ -75,16 +79,14 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr...@@ -75,16 +79,14 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr
75 });79 });
7680
77 if (!isAncestor) {81 if (!isAncestor) {
82 // Press handlers are delivered by the text runs; the container never sees them.
83 const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest;
78 return (84 return (
79 <TextAncestorContext.Provider value={contextValue}>85 <TextAncestorContext.Provider value={contextValue}>
80 <T3MarkdownTextNativeComponent86 <T3MarkdownTextNativeComponent
81 {...textDefaults}87 {...textDefaults}
82 {...rest}88 {...containerProps}
83 // ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'}
84 style={[flattenedStyle]}89 style={[flattenedStyle]}
85 // @ts-expect-error Weirdness
86 onPress={undefined}
87 onLongPress={undefined}
88 >90 >
89 {nativeChildren}91 {nativeChildren}
90 </T3MarkdownTextNativeComponent>92 </T3MarkdownTextNativeComponent>
apps/server/scripts/migrate-dev-db.ts-1
...@@ -24,7 +24,6 @@...@@ -24,7 +24,6 @@
24 * cursors never rewind.24 * cursors never rewind.
25 */25 */
2626
27// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
28import * as NodeRuntime from "@effect/platform-node/NodeRuntime";27import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
29import * as NodeServices from "@effect/platform-node/NodeServices";28import * as NodeServices from "@effect/platform-node/NodeServices";
30import * as NodeOS from "node:os";29import * as NodeOS from "node:os";
apps/server/scripts/t3-sqlite-state.ts-1
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1#!/usr/bin/env node1#!/usr/bin/env node
22
3// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
4import * as NodeRuntime from "@effect/platform-node/NodeRuntime";3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
5import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as NodeOS from "node:os";5import * as NodeOS from "node:os";
apps/server/src/assets/AttachmentUpload.ts-1
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";1import * as NodeCrypto from "node:crypto";
32
4import {3import {
apps/server/src/http.ts+1-2
...@@ -73,8 +73,7 @@ export function downloadContentDisposition(fileName?: string): string {...@@ -73,8 +73,7 @@ export function downloadContentDisposition(fileName?: string): string {
73 return "attachment";73 return "attachment";
74 }74 }
75 // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.75 // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
76 // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls.76 const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_");
77 const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_");
78 const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");77 const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
79 const needsExtended = asciiFallback !== sanitized;78 const needsExtended = asciiFallback !== sanitized;
80 const extendedName = encodeURIComponent(sanitized).replace(79 const extendedName = encodeURIComponent(sanitized).replace(
apps/server/src/relay/AgentAwarenessRelay.test.ts+37-49
...@@ -358,58 +358,45 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -358,58 +358,45 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
358 ).toEqual([activeThreadId]);358 ).toEqual([activeThreadId]);
359 });359 });
360360
361 it("signs the activity publish JWT and rejects tampering", async () => {361 it.effect("signs the activity publish JWT and rejects tampering", () =>
362 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {362 Effect.gen(function* () {
363 privateKeyEncoding: { format: "pem", type: "pkcs8" },363 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
364 publicKeyEncoding: { format: "pem", type: "spki" },364 privateKeyEncoding: { format: "pem", type: "pkcs8" },
365 });365 publicKeyEncoding: { format: "pem", type: "spki" },
366 const payload = {366 });
367 iss: "t3-env:env",367 const payload = {
368 aud: "https://relay.example.test",368 iss: "t3-env:env",
369 sub: "env",369 aud: "https://relay.example.test",
370 jti: "nonce-1",370 sub: "env",
371 iat: 100,371 jti: "nonce-1",
372 exp: 200,372 iat: 100,
373 environmentId: state.environmentId,373 exp: 200,
374 threadId: state.threadId,374 environmentId: state.environmentId,
375 state,375 threadId: state.threadId,
376 } satisfies RelayAgentActivityPublishProofPayload;376 state,
377 const proof = await Effect.runPromise(377 } satisfies RelayAgentActivityPublishProofPayload;
378 AgentAwarenessRelay.signRelayAgentActivityPublishProof({378 const proof = yield* AgentAwarenessRelay.signRelayAgentActivityPublishProof({
379 privateKey: keyPair.privateKey,379 privateKey: keyPair.privateKey,
380 payload,380 payload,
381 }),381 });
382 );382 const verify = (token: string) =>
383
384 await expect(
385 Effect.runPromise(
386 verifyRelayJwt({
387 publicKey: keyPair.publicKey,
388 token: proof,
389 typ: RELAY_ACTIVITY_PUBLISH_TYP,
390 issuer: "t3-env:env",
391 audience: "https://relay.example.test",
392 nowEpochSeconds: 150,
393 }),
394 ),
395 ).resolves.toMatchObject({ jti: "nonce-1", state });
396 await expect(
397 Effect.runPromise(
398 verifyRelayJwt({383 verifyRelayJwt({
399 publicKey: keyPair.publicKey,384 publicKey: keyPair.publicKey,
400 token: (() => {385 token,
401 const [header, body, signature = ""] = proof.split(".");
402 const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`;
403 return `${header}.${body}.${corruptedSignature}`;
404 })(),
405 typ: RELAY_ACTIVITY_PUBLISH_TYP,386 typ: RELAY_ACTIVITY_PUBLISH_TYP,
406 issuer: "t3-env:env",387 issuer: "t3-env:env",
407 audience: "https://relay.example.test",388 audience: "https://relay.example.test",
408 nowEpochSeconds: 150,389 nowEpochSeconds: 150,
409 }),390 });
410 ),391
411 ).rejects.toBeDefined();392 expect(yield* verify(proof)).toMatchObject({ jti: "nonce-1", state });
412 });393
394 const [header, body, signature = ""] = proof.split(".");
395 const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`;
396 const rejection = yield* Effect.flip(verify(`${header}.${body}.${corruptedSignature}`));
397 expect(rejection).toBeDefined();
398 }),
399 );
413400
414 it.effect("keeps the orchestration listener armed until relay config is installed", () =>401 it.effect("keeps the orchestration listener armed until relay config is installed", () =>
415 Effect.scoped(402 Effect.scoped(
...@@ -557,10 +544,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -557,10 +544,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
557 Effect.scoped(544 Effect.scoped(
558 Effect.gen(function* () {545 Effect.gen(function* () {
559 const originalFetch = globalThis.fetch;546 const originalFetch = globalThis.fetch;
560 const context = yield* Effect.context<never>();
561 const runFork = Effect.runForkWith(context);
562 const events = yield* Queue.unbounded<OrchestrationEvent>();547 const events = yield* Queue.unbounded<OrchestrationEvent>();
563 const fetchSeen = yield* Deferred.make<URL>();548 let resolveFetchSeen: (url: URL) => void = () => {};
549 const fetchSeen = new Promise<URL>((resolve) => {
550 resolveFetchSeen = resolve;
551 });
564 const userSpans: Array<string> = [];552 const userSpans: Array<string> = [];
565 const productSpans: Array<string> = [];553 const productSpans: Array<string> = [];
566 const collectingTracer = (spans: Array<string>) =>554 const collectingTracer = (spans: Array<string>) =>
...@@ -648,7 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -648,7 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
648 ? input636 ? input
649 : (input as unknown as { readonly url: string }).url,637 : (input as unknown as { readonly url: string }).url,
650 );638 );
651 runFork(Deferred.succeed(fetchSeen, url));639 resolveFetchSeen(url);
652 return Promise.resolve(Response.json({ ok: true, deliveries: [] }));640 return Promise.resolve(Response.json({ ok: true, deliveries: [] }));
653 }) as unknown as typeof fetch;641 }) as unknown as typeof fetch;
654 yield* Effect.addFinalizer(() =>642 yield* Effect.addFinalizer(() =>
...@@ -707,7 +695,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -707,7 +695,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
707 occurredAt: now,695 occurredAt: now,
708 } as unknown as OrchestrationEvent);696 } as unknown as OrchestrationEvent);
709697
710 const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds"));698 const url = yield* Effect.promise(() => fetchSeen).pipe(Effect.timeout("2 seconds"));
711 expect(url.origin).toBe("https://transport.example.test");699 expect(url.origin).toBe("https://transport.example.test");
712 expect(productSpans).toContain("makePublishProof");700 expect(productSpans).toContain("makePublishProof");
713 expect(userSpans).not.toContain("makePublishProof");701 expect(userSpans).not.toContain("makePublishProof");
apps/server/src/server.test.ts+8-8
...@@ -56,7 +56,6 @@ import * as Effect from "effect/Effect";...@@ -56,7 +56,6 @@ import * as Effect from "effect/Effect";
56import * as FileSystem from "effect/FileSystem";56import * as FileSystem from "effect/FileSystem";
57import * as Fiber from "effect/Fiber";57import * as Fiber from "effect/Fiber";
58import * as Layer from "effect/Layer";58import * as Layer from "effect/Layer";
59import * as ManagedRuntime from "effect/ManagedRuntime";
60import * as Option from "effect/Option";59import * as Option from "effect/Option";
61import * as Path from "effect/Path";60import * as Path from "effect/Path";
62import * as PubSub from "effect/PubSub";61import * as PubSub from "effect/PubSub";
...@@ -398,7 +397,9 @@ const makeBrowserOtlpPayload = (spanName: string) =>...@@ -398,7 +397,9 @@ const makeBrowserOtlpPayload = (spanName: string) =>
398 ({ close }) => Effect.promise(close),397 ({ close }) => Effect.promise(close),
399 );398 );
400399
401 const runtime = ManagedRuntime.make(400 // The exporter's batch fiber is forked while the layer builds and ticks on
401 // a wall-clock interval, so the whole tracer runs on the live clock.
402 yield* Layer.build(
402 OtlpTracer.layer({403 OtlpTracer.layer({
403 url: collector.url,404 url: collector.url,
404 exportInterval: "10 millis",405 exportInterval: "10 millis",
...@@ -411,14 +412,13 @@ const makeBrowserOtlpPayload = (spanName: string) =>...@@ -411,14 +412,13 @@ const makeBrowserOtlpPayload = (spanName: string) =>
411 },412 },
412 },413 },
413 }).pipe(Layer.provide(browserOtlpTracingLayer)),414 }).pipe(Layer.provide(browserOtlpTracingLayer)),
415 ).pipe(
416 Effect.flatMap((tracing) =>
417 Effect.void.pipe(Effect.withSpan(spanName), Effect.provideContext(tracing)),
418 ),
419 TestClock.withLive,
414 );420 );
415421
416 try {
417 yield* Effect.promise(() => runtime.runPromise(Effect.void.pipe(Effect.withSpan(spanName))));
418 } finally {
419 yield* Effect.promise(() => runtime.dispose());
420 }
421
422 const request = yield* Effect.raceFirst(422 const request = yield* Effect.raceFirst(
423 Effect.promise(() => collector.firstRequest).pipe(Effect.orDie),423 Effect.promise(() => collector.firstRequest).pipe(Effect.orDie),
424 Effect.sleep(Duration.seconds(1)).pipe(424 Effect.sleep(Duration.seconds(1)).pipe(
apps/server/src/serviceLauncher.ts-1
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2// @effect-diagnostics globalDate:off
3// @effect-diagnostics globalTimers:off2// @effect-diagnostics globalTimers:off
4// This file is shipped as a standalone bundle and copied to a stable path by3// This file is shipped as a standalone bundle and copied to a stable path by
5// `t3 service update`. Keep runtime imports limited to Node built-ins.4// `t3 service update`. Keep runtime imports limited to Node built-ins.
apps/web/src/components/settings/ThemeSearchSection.tsx+6-7
...@@ -204,13 +204,12 @@ export function ThemeSearchSection({...@@ -204,13 +204,12 @@ export function ThemeSearchSection({
204 return;204 return;
205 }205 }
206 void runSearch(debouncedQuery);206 void runSearch(debouncedQuery);
207 // `installingId` and `sortBy` are deliberately not dependencies: the207 // `sortBy` is deliberately not a direct dependency: the guards above read
208 // guards above read the current values from the fresh render closure. An208 // the current value from the fresh render closure. An install finishing
209 // install finishing reruns the search only when the query or sort changed209 // reruns the search only when the query or sort changed while it was in
210 // while it was in flight (checked via lastSearchKeyRef, recorded only210 // flight (checked via lastSearchKeyRef, recorded only once a search
211 // once a search succeeds), so the install error the user needs to see is211 // succeeds), so the install error the user needs to see is preserved
212 // preserved across that rerun.212 // across that rerun.
213 // eslint-disable-next-line react-hooks/exhaustive-deps
214 }, [open, query, debouncedQuery, installingId, runSearch]);213 }, [open, query, debouncedQuery, installingId, runSearch]);
215214
216 const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => {215 const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => {
apps/web/src/hooks/useTheme.ts+1-2
...@@ -356,8 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -356,8 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
356 syncDesktopTheme(theme, followSystem, appearanceMode);356 syncDesktopTheme(theme, followSystem, appearanceMode);
357 if (suppressTransitions) {357 if (suppressTransitions) {
358 // Force a reflow so the no-transitions class takes effect before removal358 // Force a reflow so the no-transitions class takes effect before removal
359 // oxlint-disable-next-line no-unused-expressions359 void document.documentElement.offsetHeight;
360 document.documentElement.offsetHeight;
361 requestAnimationFrame(() => {360 requestAnimationFrame(() => {
362 document.documentElement.classList.remove("no-transitions");361 document.documentElement.classList.remove("no-transitions");
363 });362 });
oxlint-plugin-t3code/rules/no-global-process-runtime.ts+1-20
...@@ -4,23 +4,8 @@ import * as Option from "effect/Option";...@@ -4,23 +4,8 @@ import * as Option from "effect/Option";
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
55
6const RUNTIME_PROPERTIES = new Set(["platform", "arch"]);6const RUNTIME_PROPERTIES = new Set(["platform", "arch"]);
7const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts";
8const NODE_OS_MODULES = new Set(["node:os", "os"]);7const NODE_OS_MODULES = new Set(["node:os", "os"]);
98
10const normalizePath = (path: string) => path.replaceAll("\\", "/");
11
12const toRepoPath = (filename: string, cwd: string) => {
13 const normalizedFilename = normalizePath(filename);
14 const normalizedCwd = normalizePath(cwd).replace(/\/+$/u, "");
15 const prefix = `${normalizedCwd}/`;
16 return normalizedFilename.startsWith(prefix)
17 ? normalizedFilename.slice(prefix.length)
18 : normalizedFilename;
19};
20
21const isHostProcessReferenceFile = (filename: string, cwd: string) =>
22 toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE;
23
24const isGlobalProcessObject = (node: unknown): boolean => {9const isGlobalProcessObject = (node: unknown): boolean => {
25 const expression = unwrapExpression(node);10 const expression = unwrapExpression(node);
26 if (isIdentifier(expression, "process")) return true;11 if (isIdentifier(expression, "process")) return true;
...@@ -48,7 +33,7 @@ export default defineRule({...@@ -48,7 +33,7 @@ export default defineRule({
48 type: "problem",33 type: "problem",
49 docs: {34 docs: {
50 description:35 description:
51 "Disallow direct host runtime platform/architecture reads outside the shared host process references.",36 "Disallow direct host runtime platform/architecture reads; the shared host process references are exempted in the lint config.",
52 },37 },
53 },38 },
54 createOnce(context) {39 createOnce(context) {
...@@ -117,8 +102,6 @@ export default defineRule({...@@ -117,8 +102,6 @@ export default defineRule({
117 before: resetBindings,102 before: resetBindings,
118 ImportDeclaration: trackImportDeclaration,103 ImportDeclaration: trackImportDeclaration,
119 MemberExpression(node) {104 MemberExpression(node) {
120 if (isHostProcessReferenceFile(context.filename, context.cwd)) return;
121
122 const property = getPropertyName(node.property);105 const property = getPropertyName(node.property);
123 if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return;106 if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return;
124 if (!isGlobalProcessObject(node.object)) return;107 if (!isGlobalProcessObject(node.object)) return;
...@@ -129,8 +112,6 @@ export default defineRule({...@@ -129,8 +112,6 @@ export default defineRule({
129 });112 });
130 },113 },
131 CallExpression(node) {114 CallExpression(node) {
132 if (isHostProcessReferenceFile(context.filename, context.cwd)) return;
133
134 const property = getNodeOsRuntimeCall(node.callee);115 const property = getNodeOsRuntimeCall(node.callee);
135 if (Option.isNone(property)) return;116 if (Option.isNone(property)) return;
136117
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts+31
...@@ -71,3 +71,34 @@ productionRule.valid(...@@ -71,3 +71,34 @@ productionRule.valid(
71 export const main = () => Effect.runPromise(Effect.void);71 export const main = () => Effect.runPromise(Effect.void);
72 `,72 `,
73);73);
74
75const legacyRule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests", {
76 filename: "legacy.test.ts",
77 ruleOptions: [{ maxOccurrences: 2 }],
78});
79
80describe("t3code/no-manual-effect-runtime-in-tests with maxOccurrences", () => {
81 legacyRule.valid(
82 "allows occurrences up to the ceiling",
83 `
84 import * as Effect from "effect/Effect";
85
86 test("first", () => Effect.runSync(Effect.void));
87 test("second", () => Effect.runSync(Effect.void));
88 `,
89 );
90
91 legacyRule.invalid(
92 "reports occurrences beyond the ceiling",
93 `
94 import * as Effect from "effect/Effect";
95
96 test("first", () => Effect.runSync(Effect.void));
97 test("second", () => Effect.runSync(Effect.void));
98 test("third", () => Effect.runSync(Effect.void));
99 `,
100 (output) => {
101 assert.equal(output.match(/no-manual-effect-runtime-in-tests/g)?.length, 1);
102 },
103 );
104});
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts+27-39
...@@ -19,44 +19,17 @@ const EFFECT_RUNTIME_METHODS = new Set([...@@ -19,44 +19,17 @@ const EFFECT_RUNTIME_METHODS = new Set([
19 "runSyncWith",19 "runSyncWith",
20]);20]);
2121
22// Existing manual runners are tracked as debt. The rule permits no net-new22// Existing manual runners are tracked as debt through the `maxOccurrences`
23// occurrences in these files, while unlisted test files must have zero.23// option, set per file in the lint config. The rule permits no net-new
24const LEGACY_BASELINE = new Map<string, number>([24// occurrences in those files, while every other test file must have zero.
25 ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1],25const readMaxOccurrences = (options: ReadonlyArray<unknown>): number => {
26 ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2],26 const [first] = options;
27 ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2],27 return typeof first === "object" &&
28 ["apps/server/src/orchestration/commandInvariants.test.ts", 6],28 first !== null &&
29 ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42],29 "maxOccurrences" in first &&
30 ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5],30 typeof first.maxOccurrences === "number"
31 ["apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts", 4],31 ? first.maxOccurrences
32 ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70],32 : 0;
33 ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31],
34 ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2],
35 ["apps/server/src/orchestration/projector.test.ts", 20],
36 ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4],
37 ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1],
38 ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2],
39 ["apps/server/src/provider/Layers/CodexAdapter.test.ts", 1],
40 ["apps/server/src/provider/Layers/CodexSessionRuntime.test.ts", 5],
41 ["apps/server/src/provider/Layers/CursorAdapter.test.ts", 1],
42 ["apps/server/src/provider/Layers/CursorProvider.test.ts", 4],
43 ["apps/server/src/provider/Layers/ProviderService.test.ts", 2],
44 ["apps/server/src/provider/Layers/ProviderSessionReaper.test.ts", 21],
45 ["apps/server/src/relay/AgentAwarenessRelay.test.ts", 4],
46 ["apps/server/src/server.test.ts", 1],
47 ["apps/web/src/cloud/dpop.test.ts", 2],
48 ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1],
49 ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7],
50 ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1],
51 ["packages/client-runtime/src/wsTransport.test.ts", 2],
52]);
53
54const baselineFor = (filename: string): number => {
55 const normalized = filename.replaceAll("\\", "/");
56 for (const [suffix, count] of LEGACY_BASELINE) {
57 if (normalized.endsWith(suffix)) return count;
58 }
59 return 0;
60};33};
6134
62const manualRunnerName = (callee: unknown): Option.Option<string> => {35const manualRunnerName = (callee: unknown): Option.Option<string> => {
...@@ -87,11 +60,26 @@ export default defineRule({...@@ -87,11 +60,26 @@ export default defineRule({
87 description:60 description:
88 "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.",61 "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.",
89 },62 },
63 schema: [
64 {
65 type: "object",
66 properties: {
67 maxOccurrences: {
68 type: "integer",
69 minimum: 0,
70 description:
71 "Legacy debt ceiling for this file: occurrences beyond this count are reported.",
72 },
73 },
74 additionalProperties: false,
75 },
76 ],
77 defaultOptions: [{ maxOccurrences: 0 }],
90 },78 },
91 create(context) {79 create(context) {
92 if (!TEST_FILE_PATTERN.test(context.filename)) return {};80 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
9381
94 const allowedCount = baselineFor(context.filename);82 const allowedCount = readMaxOccurrences(context.options);
95 let occurrenceCount = 0;83 let occurrenceCount = 0;
9684
97 return {85 return {
oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts+7-20
...@@ -7,15 +7,11 @@ const guardedMobileFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-them...@@ -7,15 +7,11 @@ const guardedMobileFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-them
7});7});
8const reviewedInteropFile = createOxlintRuleHarness(8const reviewedInteropFile = createOxlintRuleHarness(
9 "t3code/no-mobile-uniwind-theme-escape-hatches",9 "t3code/no-mobile-uniwind-theme-escape-hatches",
10 { filename: "apps/mobile/src/features/home/HomeHeader.tsx" },10 {
11 filename: "apps/mobile/src/features/home/HomeHeader.tsx",
12 ruleOptions: [{ allowUniwindTheme: true }],
13 },
11);14);
12const gitOverlayInteropFile = createOxlintRuleHarness(
13 "t3code/no-mobile-uniwind-theme-escape-hatches",
14 { filename: "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx" },
15);
16const webFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-theme-escape-hatches", {
17 filename: "apps/web/src/ThemeSurface.tsx",
18});
1915
20describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {16describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {
21 guardedMobileFile.valid(17 guardedMobileFile.valid(
...@@ -93,18 +89,9 @@ describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {...@@ -93,18 +89,9 @@ describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {
93 `,89 `,
94 );90 );
9591
96 gitOverlayInteropFile.valid(92 reviewedInteropFile.invalid(
97 "allows the native liquid-glass theme boundary",93 "still reports appearance variants in reviewed interop boundaries",
98 `94 `const surface = <View className="bg-white dark:bg-black" />;`,
99 import { useUniwindTheme } from "../../lib/useUniwindTheme";
100
101 export const tint = useUniwindTheme()["--color-glass-surface"];
102 `,
103 );
104
105 webFile.valid(
106 "does not impose the mobile custom-theme policy on web code",
107 `const surface = <div className="bg-white dark:bg-black" />;`,
108 );95 );
10996
110 guardedMobileFile.invalid(97 guardedMobileFile.invalid(
oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts+29-42
...@@ -3,44 +3,9 @@ import * as Option from "effect/Option";...@@ -3,44 +3,9 @@ import * as Option from "effect/Option";
33
4import { getPropertyName, unwrapExpression } from "../utils.ts";4import { getPropertyName, unwrapExpression } from "../utils.ts";
55
6const MOBILE_SOURCE_MARKER = "/apps/mobile/src/";
7const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u;6const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u;
8const APPEARANCE_VARIANT_MESSAGE =7const APPEARANCE_VARIANT_MESSAGE =
9 "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token.";8 "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token.";
10const THEME_INTEROP_ALLOWLIST = new Set([
11 "features/archive/ArchivedThreadsScreen.tsx",
12 "features/connection/ConnectionsNewRouteScreen.tsx",
13 "features/files/FileMarkdownPreview.tsx",
14 "features/files/SourceFileSurface.tsx",
15 "features/files/ThreadFilesRouteScreen.tsx",
16 "features/files/thread-file-navigator-pane.tsx",
17 "features/home/HomeHeader.tsx",
18 "features/review/ReviewSheet.tsx",
19 "features/review/useNativeReviewDiffBridge.ts",
20 "features/settings/SettingsEnvironmentsRouteScreen.tsx",
21 "features/settings/appearance/components/AppearancePreviews.tsx",
22 "features/settings/appearance/components/FontSizeSliderRow.tsx",
23 "features/threads/GitActionProgressOverlay.tsx",
24 "features/threads/NewTaskContextPickerScreens.tsx",
25 "features/threads/NewTaskDraftScreen.tsx",
26 "features/threads/ThreadComposer.tsx",
27 "features/threads/ThreadFeed.tsx",
28 "features/threads/ThreadSettingsSheet.tsx",
29 "features/threads/git/GitOverviewSheet.tsx",
30 "features/threads/thread-list-items.tsx",
31 "features/threads/thread-list-v2-items.tsx",
32 "lib/useMobileNavigationTheme.ts",
33 "native/T3ComposerEditor.ios.tsx",
34 "native/T3ComposerEditor.native.tsx",
35]);
36
37const mobileSourcePath = (filename: string): string | undefined => {
38 const normalized = `/${filename.replaceAll("\\", "/")}`;
39 const markerIndex = normalized.lastIndexOf(MOBILE_SOURCE_MARKER);
40 return markerIndex === -1
41 ? undefined
42 : normalized.slice(markerIndex + MOBILE_SOURCE_MARKER.length);
43};
449
45const literalStringValue = (node: unknown): Option.Option<string> => {10const literalStringValue = (node: unknown): Option.Option<string> => {
46 if (typeof node !== "object" || node === null) return Option.none();11 if (typeof node !== "object" || node === null) return Option.none();
...@@ -54,18 +19,40 @@ const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.t...@@ -54,18 +19,40 @@ const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.t
54const importsModule = (source: string, modulePath: string): boolean =>19const importsModule = (source: string, modulePath: string): boolean =>
55 source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath);20 source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath);
5621
22const readAllowUniwindTheme = (options: ReadonlyArray<unknown>): boolean => {
23 const [first] = options;
24 return (
25 typeof first === "object" &&
26 first !== null &&
27 "allowUniwindTheme" in first &&
28 first.allowUniwindTheme === true
29 );
30};
31
57export default defineRule({32export default defineRule({
58 meta: {33 meta: {
59 type: "problem",34 type: "problem",
60 docs: {35 docs: {
61 description:36 description:
62 "Keep mobile theme styling on semantic Uniwind classes and reviewed native interop boundaries.",37 "Keep mobile theme styling on semantic Uniwind classes. Scope the rule to mobile sources and exempt reviewed native interop boundaries in the lint config.",
63 },38 },
39 schema: [
40 {
41 type: "object",
42 properties: {
43 allowUniwindTheme: {
44 type: "boolean",
45 description:
46 "Permit useUniwindTheme in a reviewed native/third-party interop boundary that cannot consume a className.",
47 },
48 },
49 additionalProperties: false,
50 },
51 ],
52 defaultOptions: [{ allowUniwindTheme: false }],
64 },53 },
65 create(context) {54 create(context) {
66 const sourcePath = mobileSourcePath(context.filename);55 const allowUniwindTheme = readAllowUniwindTheme(context.options);
67 if (sourcePath === undefined) return {};
68
69 const uniwindNamespaces = new Set<Variable>();56 const uniwindNamespaces = new Set<Variable>();
7057
71 const resolveVariable = (node: unknown): Variable | undefined => {58 const resolveVariable = (node: unknown): Variable | undefined => {
...@@ -131,13 +118,13 @@ export default defineRule({...@@ -131,13 +118,13 @@ export default defineRule({
131118
132 if (119 if (
133 !isTypeOnly &&120 !isTypeOnly &&
134 importsModule(source.value, "/useUniwindTheme") &&121 !allowUniwindTheme &&
135 !THEME_INTEROP_ALLOWLIST.has(sourcePath)122 importsModule(source.value, "/useUniwindTheme")
136 ) {123 ) {
137 context.report({124 context.report({
138 node: specifier,125 node: specifier,
139 message:126 message:
140 "Use className for theme styling, or review and add this native/third-party interop boundary to the lint allowlist.",127 "Use className for theme styling, or review this native/third-party interop boundary and enable allowUniwindTheme for it in the lint config.",
141 });128 });
142 }129 }
143 }130 }
oxlint-plugin-t3code/test/utils.ts+3-1
...@@ -61,6 +61,8 @@ interface RuleHarness {...@@ -61,6 +61,8 @@ interface RuleHarness {
6161
62interface RuleHarnessOptions {62interface RuleHarnessOptions {
63 readonly filename?: string;63 readonly filename?: string;
64 /** Rule options, as they would appear after the severity in the lint config. */
65 readonly ruleOptions?: ReadonlyArray<unknown>;
64}66}
6567
66const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>68const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
...@@ -111,7 +113,7 @@ export const createOxlintRuleHarness = (...@@ -111,7 +113,7 @@ export const createOxlintRuleHarness = (
111 configPath,113 configPath,
112 yield* encodeOxlintConfig({114 yield* encodeOxlintConfig({
113 jsPlugins: [{ name: "t3code", specifier: pluginPath }],115 jsPlugins: [{ name: "t3code", specifier: pluginPath }],
114 rules: { [ruleName]: "error" },116 rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] },
115 }),117 }),
116 );118 );
117 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });119 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
packages/client-runtime/src/relay/managedRelayState.test.ts+30-22
...@@ -243,30 +243,38 @@ describe("createManagedRelayQueryManager", () => {...@@ -243,30 +243,38 @@ describe("createManagedRelayQueryManager", () => {
243 }),243 }),
244 );244 );
245245
246 it("emits credential changes only when the managed relay account changes", async () => {246 it.effect("emits credential changes only when the managed relay account changes", () =>
247 setManagedRelaySession(registry, {247 Effect.gen(function* () {
248 accountId: "account-1",248 setManagedRelaySession(registry, {
249 readClerkToken: () => Promise.resolve("first-token"),249 accountId: "account-1",
250 });250 readClerkToken: () => Promise.resolve("first-token"),
251 const changes = Effect.runPromise(251 });
252 managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect),252 const changes = yield* managedRelayAccountChanges(registry).pipe(
253 );253 Stream.take(2),
254 await vi.waitFor(() => {254 Stream.runCollect,
255 expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0);255 Effect.forkChild,
256 });256 );
257 yield* Effect.promise(() =>
258 vi.waitFor(() => {
259 expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(
260 0,
261 );
262 }),
263 );
257264
258 setManagedRelaySession(registry, {265 setManagedRelaySession(registry, {
259 accountId: "account-1",266 accountId: "account-1",
260 readClerkToken: () => Promise.resolve("refreshed-token"),267 readClerkToken: () => Promise.resolve("refreshed-token"),
261 });268 });
262 setManagedRelaySession(registry, {269 setManagedRelaySession(registry, {
263 accountId: "account-2",270 accountId: "account-2",
264 readClerkToken: () => Promise.resolve("second-token"),271 readClerkToken: () => Promise.resolve("second-token"),
265 });272 });
266 setManagedRelaySession(registry, null);273 setManagedRelaySession(registry, null);
267274
268 expect(Array.from(await changes)).toEqual(["account-2", null]);275 expect(Array.from(yield* Fiber.join(changes))).toEqual(["account-2", null]);
269 });276 }),
277 );
270278
271 it("shares one Clerk token read across concurrent relay list and status queries", async () => {279 it("shares one Clerk token read across concurrent relay list and status queries", async () => {
272 const secondEnvironment = {280 const secondEnvironment = {
scripts/build-desktop-artifact.test.ts-1
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off - packaged-archive fixtures compute the sidecar digest with the same Node primitive as the builder.
2import * as NodeCrypto from "node:crypto";1import * as NodeCrypto from "node:crypto";
32
4import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
vite.config.ts+66-3
...@@ -26,7 +26,6 @@ export default defineConfig({...@@ -26,7 +26,6 @@ export default defineConfig({
26 },26 },
27 fmt: {27 fmt: {
28 ignorePatterns: [28 ignorePatterns: [
29 ".reference",
30 ".repos/**",29 ".repos/**",
31 ".alchemy",30 ".alchemy",
32 "dist",31 "dist",
...@@ -37,7 +36,6 @@ export default defineConfig({...@@ -37,7 +36,6 @@ export default defineConfig({
37 "**/routeTree.gen.ts",36 "**/routeTree.gen.ts",
38 "apps/mobile/android/**",37 "apps/mobile/android/**",
39 "apps/mobile/ios/**",38 "apps/mobile/ios/**",
40 "apps/web/src/lib/vendor/qrcodegen.ts",
41 "apps/mobile/uniwind-types.d.ts",39 "apps/mobile/uniwind-types.d.ts",
42 "*.icon/**",40 "*.icon/**",
43 ],41 ],
...@@ -119,11 +117,76 @@ export default defineConfig({...@@ -119,11 +117,76 @@ export default defineConfig({
119 "t3code/no-global-process-runtime": "error",117 "t3code/no-global-process-runtime": "error",
120 "t3code/no-inline-schema-compile": "warn",118 "t3code/no-inline-schema-compile": "warn",
121 "t3code/no-manual-effect-runtime-in-tests": "error",119 "t3code/no-manual-effect-runtime-in-tests": "error",
122 "t3code/no-mobile-uniwind-theme-escape-hatches": "error",
123 "t3code/no-native-title-tooltip": "error",120 "t3code/no-native-title-tooltip": "error",
124 "t3code/namespace-node-imports": "error",121 "t3code/namespace-node-imports": "error",
125 },122 },
123 overrides: [
124 {
125 // The one place that reads the host platform to seed the injected references.
126 files: ["packages/shared/src/hostProcess.ts"],
127 rules: { "t3code/no-global-process-runtime": "off" },
128 },
129 {
130 files: ["apps/mobile/src/**"],
131 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },
132 },
133 {
134 // Reviewed native and third-party interop boundaries that cannot consume a className.
135 files: [
136 "apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx",
137 "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx",
138 "apps/mobile/src/features/files/FileMarkdownPreview.tsx",
139 "apps/mobile/src/features/files/SourceFileSurface.tsx",
140 "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx",
141 "apps/mobile/src/features/files/thread-file-navigator-pane.tsx",
142 "apps/mobile/src/features/home/HomeHeader.tsx",
143 "apps/mobile/src/features/review/ReviewSheet.tsx",
144 "apps/mobile/src/features/review/useNativeReviewDiffBridge.ts",
145 "apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx",
146 "apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx",
147 "apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx",
148 "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx",
149 "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx",
150 "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx",
151 "apps/mobile/src/features/threads/ThreadComposer.tsx",
152 "apps/mobile/src/features/threads/ThreadFeed.tsx",
153 "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx",
154 "apps/mobile/src/features/threads/git/GitOverviewSheet.tsx",
155 "apps/mobile/src/features/threads/thread-list-items.tsx",
156 "apps/mobile/src/features/threads/thread-list-v2-items.tsx",
157 "apps/mobile/src/lib/useMobileNavigationTheme.ts",
158 "apps/mobile/src/native/T3ComposerEditor.ios.tsx",
159 "apps/mobile/src/native/T3ComposerEditor.native.tsx",
160 ],
161 rules: {
162 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],
163 },
164 },
165 // Legacy manual Effect runners tracked as debt: no net-new occurrences.
166 // Lower a ceiling when you migrate a file, and delete its entry at zero.
167 ...Object.entries({
168 "apps/server/src/orchestration/Layers/CheckpointReactor.test.ts": 42,
169 "apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts": 5,
170 "apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts": 4,
171 "apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts": 66,
172 "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 29,
173 "apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts": 2,
174 "apps/server/src/orchestration/commandInvariants.test.ts": 5,
175 "apps/server/src/orchestration/projector.test.ts": 20,
176 "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1,
177 "apps/server/src/provider/Layers/CodexSessionRuntime.test.ts": 5,
178 "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1,
179 "apps/server/src/provider/Layers/CursorProvider.test.ts": 1,
180 "apps/server/src/provider/Layers/ProviderService.test.ts": 2,
181 "apps/server/src/provider/Layers/ProviderSessionReaper.test.ts": 12,
182 "apps/server/src/provider/acp/CursorAcpSupport.test.ts": 1,
183 }).map(([file, maxOccurrences]) => {
184 const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }];
185 return { files: [file], rules: { "t3code/no-manual-effect-runtime-in-tests": rule } };
186 }),
187 ],
126 options: {188 options: {
189 reportUnusedDisableDirectives: "error",
127 // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.190 // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.
128 typeAware: false,191 typeAware: false,
129 typeCheck: false,192 typeCheck: false,