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// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime.
21import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts";
32import { contextBridge, ipcRenderer } from "electron";
43
apps/mobile/metro.config.js+1-2
......@@ -36,8 +36,7 @@ config.resolver = {
3636 new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`),
3737 ],
3838 extraNodeModules: {
39 // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread
40 ...(config.resolver?.extraNodeModules ?? {}),
39 ...config.resolver?.extraNodeModules,
4140 shiki: mobileShikiRoot,
4241 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
4342 "@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]>([
99 StyleSheet.create({}),
1010]);
1111
12const textDefaults: TextProps = {
12const textDefaults = {
1313 allowFontScaling: true,
1414 selectable: true,
15};
15} satisfies TextProps;
1616
1717const useTextAncestorContext = () => React.useContext(TextAncestorContext);
1818
......@@ -28,7 +28,11 @@ export type ContextMenuActionEvent = {
2828 nativeEvent: { target: number; actionIdentifier: string };
2929};
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"> & {
3236 uiTextView?: boolean;
3337 contextMenuConfig?: string;
3438 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
......@@ -75,16 +79,14 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr
7579 });
7680
7781 if (!isAncestor) {
82 // Press handlers are delivered by the text runs; the container never sees them.
83 const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest;
7884 return (
7985 <TextAncestorContext.Provider value={contextValue}>
8086 <T3MarkdownTextNativeComponent
8187 {...textDefaults}
82 {...rest}
83 // ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'}
88 {...containerProps}
8489 style={[flattenedStyle]}
85 // @ts-expect-error Weirdness
86 onPress={undefined}
87 onLongPress={undefined}
8890 >
8991 {nativeChildren}
9092 </T3MarkdownTextNativeComponent>
apps/server/scripts/migrate-dev-db.ts-1
......@@ -24,7 +24,6 @@
2424 * cursors never rewind.
2525 */
2626
27// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
2827import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2928import * as NodeServices from "@effect/platform-node/NodeServices";
3029import * as NodeOS from "node:os";
apps/server/scripts/t3-sqlite-state.ts-1
......@@ -1,6 +1,5 @@
11#!/usr/bin/env node
22
3// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
43import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
54import * as NodeServices from "@effect/platform-node/NodeServices";
65import * as NodeOS from "node:os";
apps/server/src/assets/AttachmentUpload.ts-1
......@@ -1,4 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off
21import * as NodeCrypto from "node:crypto";
32
43import {
apps/server/src/http.ts+1-2
......@@ -73,8 +73,7 @@ export function downloadContentDisposition(fileName?: string): string {
7373 return "attachment";
7474 }
7575 // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
76 // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls.
77 const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_");
76 const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_");
7877 const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
7978 const needsExtended = asciiFallback !== sanitized;
8079 const extendedName = encodeURIComponent(sanitized).replace(
apps/server/src/relay/AgentAwarenessRelay.test.ts+37-49
......@@ -358,58 +358,45 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
358358 ).toEqual([activeThreadId]);
359359 });
360360
361 it("signs the activity publish JWT and rejects tampering", async () => {
362 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
363 privateKeyEncoding: { format: "pem", type: "pkcs8" },
364 publicKeyEncoding: { format: "pem", type: "spki" },
365 });
366 const payload = {
367 iss: "t3-env:env",
368 aud: "https://relay.example.test",
369 sub: "env",
370 jti: "nonce-1",
371 iat: 100,
372 exp: 200,
373 environmentId: state.environmentId,
374 threadId: state.threadId,
375 state,
376 } satisfies RelayAgentActivityPublishProofPayload;
377 const proof = await Effect.runPromise(
378 AgentAwarenessRelay.signRelayAgentActivityPublishProof({
361 it.effect("signs the activity publish JWT and rejects tampering", () =>
362 Effect.gen(function* () {
363 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
364 privateKeyEncoding: { format: "pem", type: "pkcs8" },
365 publicKeyEncoding: { format: "pem", type: "spki" },
366 });
367 const payload = {
368 iss: "t3-env:env",
369 aud: "https://relay.example.test",
370 sub: "env",
371 jti: "nonce-1",
372 iat: 100,
373 exp: 200,
374 environmentId: state.environmentId,
375 threadId: state.threadId,
376 state,
377 } satisfies RelayAgentActivityPublishProofPayload;
378 const proof = yield* AgentAwarenessRelay.signRelayAgentActivityPublishProof({
379379 privateKey: keyPair.privateKey,
380380 payload,
381 }),
382 );
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(
381 });
382 const verify = (token: string) =>
398383 verifyRelayJwt({
399384 publicKey: keyPair.publicKey,
400 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 })(),
385 token,
405386 typ: RELAY_ACTIVITY_PUBLISH_TYP,
406387 issuer: "t3-env:env",
407388 audience: "https://relay.example.test",
408389 nowEpochSeconds: 150,
409 }),
410 ),
411 ).rejects.toBeDefined();
412 });
390 });
391
392 expect(yield* verify(proof)).toMatchObject({ jti: "nonce-1", state });
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
414401 it.effect("keeps the orchestration listener armed until relay config is installed", () =>
415402 Effect.scoped(
......@@ -557,10 +544,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
557544 Effect.scoped(
558545 Effect.gen(function* () {
559546 const originalFetch = globalThis.fetch;
560 const context = yield* Effect.context<never>();
561 const runFork = Effect.runForkWith(context);
562547 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 });
564552 const userSpans: Array<string> = [];
565553 const productSpans: Array<string> = [];
566554 const collectingTracer = (spans: Array<string>) =>
......@@ -648,7 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
648636 ? input
649637 : (input as unknown as { readonly url: string }).url,
650638 );
651 runFork(Deferred.succeed(fetchSeen, url));
639 resolveFetchSeen(url);
652640 return Promise.resolve(Response.json({ ok: true, deliveries: [] }));
653641 }) as unknown as typeof fetch;
654642 yield* Effect.addFinalizer(() =>
......@@ -707,7 +695,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
707695 occurredAt: now,
708696 } 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"));
711699 expect(url.origin).toBe("https://transport.example.test");
712700 expect(productSpans).toContain("makePublishProof");
713701 expect(userSpans).not.toContain("makePublishProof");
apps/server/src/server.test.ts+8-8
......@@ -56,7 +56,6 @@ import * as Effect from "effect/Effect";
5656import * as FileSystem from "effect/FileSystem";
5757import * as Fiber from "effect/Fiber";
5858import * as Layer from "effect/Layer";
59import * as ManagedRuntime from "effect/ManagedRuntime";
6059import * as Option from "effect/Option";
6160import * as Path from "effect/Path";
6261import * as PubSub from "effect/PubSub";
......@@ -398,7 +397,9 @@ const makeBrowserOtlpPayload = (spanName: string) =>
398397 ({ close }) => Effect.promise(close),
399398 );
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(
402403 OtlpTracer.layer({
403404 url: collector.url,
404405 exportInterval: "10 millis",
......@@ -411,14 +412,13 @@ const makeBrowserOtlpPayload = (spanName: string) =>
411412 },
412413 },
413414 }).pipe(Layer.provide(browserOtlpTracingLayer)),
415 ).pipe(
416 Effect.flatMap((tracing) =>
417 Effect.void.pipe(Effect.withSpan(spanName), Effect.provideContext(tracing)),
418 ),
419 TestClock.withLive,
414420 );
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
422422 const request = yield* Effect.raceFirst(
423423 Effect.promise(() => collector.firstRequest).pipe(Effect.orDie),
424424 Effect.sleep(Duration.seconds(1)).pipe(
apps/server/src/serviceLauncher.ts-1
......@@ -1,5 +1,4 @@
11// @effect-diagnostics nodeBuiltinImport:off
2// @effect-diagnostics globalDate:off
32// @effect-diagnostics globalTimers:off
43// This file is shipped as a standalone bundle and copied to a stable path by
54// `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({
204204 return;
205205 }
206206 void runSearch(debouncedQuery);
207 // `installingId` and `sortBy` are deliberately not dependencies: the
208 // guards above read the current values from the fresh render closure. An
209 // install finishing reruns the search only when the query or sort changed
210 // while it was in flight (checked via lastSearchKeyRef, recorded only
211 // once a search succeeds), so the install error the user needs to see is
212 // preserved across that rerun.
213 // eslint-disable-next-line react-hooks/exhaustive-deps
207 // `sortBy` is deliberately not a direct dependency: the guards above read
208 // the current value from the fresh render closure. An install finishing
209 // reruns the search only when the query or sort changed while it was in
210 // flight (checked via lastSearchKeyRef, recorded only once a search
211 // succeeds), so the install error the user needs to see is preserved
212 // across that rerun.
214213 }, [open, query, debouncedQuery, installingId, runSearch]);
215214
216215 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
356356 syncDesktopTheme(theme, followSystem, appearanceMode);
357357 if (suppressTransitions) {
358358 // Force a reflow so the no-transitions class takes effect before removal
359 // oxlint-disable-next-line no-unused-expressions
360 document.documentElement.offsetHeight;
359 void document.documentElement.offsetHeight;
361360 requestAnimationFrame(() => {
362361 document.documentElement.classList.remove("no-transitions");
363362 });
oxlint-plugin-t3code/rules/no-global-process-runtime.ts+1-20
......@@ -4,23 +4,8 @@ import * as Option from "effect/Option";
44import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
55
66const RUNTIME_PROPERTIES = new Set(["platform", "arch"]);
7const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts";
87const 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
249const isGlobalProcessObject = (node: unknown): boolean => {
2510 const expression = unwrapExpression(node);
2611 if (isIdentifier(expression, "process")) return true;
......@@ -48,7 +33,7 @@ export default defineRule({
4833 type: "problem",
4934 docs: {
5035 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.",
5237 },
5338 },
5439 createOnce(context) {
......@@ -117,8 +102,6 @@ export default defineRule({
117102 before: resetBindings,
118103 ImportDeclaration: trackImportDeclaration,
119104 MemberExpression(node) {
120 if (isHostProcessReferenceFile(context.filename, context.cwd)) return;
121
122105 const property = getPropertyName(node.property);
123106 if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return;
124107 if (!isGlobalProcessObject(node.object)) return;
......@@ -129,8 +112,6 @@ export default defineRule({
129112 });
130113 },
131114 CallExpression(node) {
132 if (isHostProcessReferenceFile(context.filename, context.cwd)) return;
133
134115 const property = getNodeOsRuntimeCall(node.callee);
135116 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(
7171 export const main = () => Effect.runPromise(Effect.void);
7272 `,
7373);
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([
1919 "runSyncWith",
2020]);
2121
22// Existing manual runners are tracked as debt. The rule permits no net-new
23// occurrences in these files, while unlisted test files must have zero.
24const LEGACY_BASELINE = new Map<string, number>([
25 ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1],
26 ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2],
27 ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2],
28 ["apps/server/src/orchestration/commandInvariants.test.ts", 6],
29 ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42],
30 ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5],
31 ["apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts", 4],
32 ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70],
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;
22// Existing manual runners are tracked as debt through the `maxOccurrences`
23// option, set per file in the lint config. The rule permits no net-new
24// occurrences in those files, while every other test file must have zero.
25const readMaxOccurrences = (options: ReadonlyArray<unknown>): number => {
26 const [first] = options;
27 return typeof first === "object" &&
28 first !== null &&
29 "maxOccurrences" in first &&
30 typeof first.maxOccurrences === "number"
31 ? first.maxOccurrences
32 : 0;
6033};
6134
6235const manualRunnerName = (callee: unknown): Option.Option<string> => {
......@@ -87,11 +60,26 @@ export default defineRule({
8760 description:
8861 "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.",
8962 },
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 }],
9078 },
9179 create(context) {
9280 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
9381
94 const allowedCount = baselineFor(context.filename);
82 const allowedCount = readMaxOccurrences(context.options);
9583 let occurrenceCount = 0;
9684
9785 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
77});
88const reviewedInteropFile = createOxlintRuleHarness(
99 "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 },
1114);
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
2016describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {
2117 guardedMobileFile.valid(
......@@ -93,18 +89,9 @@ describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {
9389 `,
9490 );
9591
96 gitOverlayInteropFile.valid(
97 "allows the native liquid-glass theme boundary",
98 `
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" />;`,
92 reviewedInteropFile.invalid(
93 "still reports appearance variants in reviewed interop boundaries",
94 `const surface = <View className="bg-white dark:bg-black" />;`,
10895 );
10996
11097 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";
33
44import { getPropertyName, unwrapExpression } from "../utils.ts";
55
6const MOBILE_SOURCE_MARKER = "/apps/mobile/src/";
76const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u;
87const APPEARANCE_VARIANT_MESSAGE =
98 "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
4510const literalStringValue = (node: unknown): Option.Option<string> => {
4611 if (typeof node !== "object" || node === null) return Option.none();
......@@ -54,18 +19,40 @@ const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.t
5419const importsModule = (source: string, modulePath: string): boolean =>
5520 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
5732export default defineRule({
5833 meta: {
5934 type: "problem",
6035 docs: {
6136 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.",
6338 },
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 }],
6453 },
6554 create(context) {
66 const sourcePath = mobileSourcePath(context.filename);
67 if (sourcePath === undefined) return {};
68
55 const allowUniwindTheme = readAllowUniwindTheme(context.options);
6956 const uniwindNamespaces = new Set<Variable>();
7057
7158 const resolveVariable = (node: unknown): Variable | undefined => {
......@@ -131,13 +118,13 @@ export default defineRule({
131118
132119 if (
133120 !isTypeOnly &&
134 importsModule(source.value, "/useUniwindTheme") &&
135 !THEME_INTEROP_ALLOWLIST.has(sourcePath)
121 !allowUniwindTheme &&
122 importsModule(source.value, "/useUniwindTheme")
136123 ) {
137124 context.report({
138125 node: specifier,
139126 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.",
141128 });
142129 }
143130 }
oxlint-plugin-t3code/test/utils.ts+3-1
......@@ -61,6 +61,8 @@ interface RuleHarness {
6161
6262interface RuleHarnessOptions {
6363 readonly filename?: string;
64 /** Rule options, as they would appear after the severity in the lint config. */
65 readonly ruleOptions?: ReadonlyArray<unknown>;
6466}
6567
6668const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
......@@ -111,7 +113,7 @@ export const createOxlintRuleHarness = (
111113 configPath,
112114 yield* encodeOxlintConfig({
113115 jsPlugins: [{ name: "t3code", specifier: pluginPath }],
114 rules: { [ruleName]: "error" },
116 rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] },
115117 }),
116118 );
117119 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
packages/client-runtime/src/relay/managedRelayState.test.ts+30-22
......@@ -243,30 +243,38 @@ describe("createManagedRelayQueryManager", () => {
243243 }),
244244 );
245245
246 it("emits credential changes only when the managed relay account changes", async () => {
247 setManagedRelaySession(registry, {
248 accountId: "account-1",
249 readClerkToken: () => Promise.resolve("first-token"),
250 });
251 const changes = Effect.runPromise(
252 managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect),
253 );
254 await vi.waitFor(() => {
255 expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0);
256 });
246 it.effect("emits credential changes only when the managed relay account changes", () =>
247 Effect.gen(function* () {
248 setManagedRelaySession(registry, {
249 accountId: "account-1",
250 readClerkToken: () => Promise.resolve("first-token"),
251 });
252 const changes = yield* managedRelayAccountChanges(registry).pipe(
253 Stream.take(2),
254 Stream.runCollect,
255 Effect.forkChild,
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, {
259 accountId: "account-1",
260 readClerkToken: () => Promise.resolve("refreshed-token"),
261 });
262 setManagedRelaySession(registry, {
263 accountId: "account-2",
264 readClerkToken: () => Promise.resolve("second-token"),
265 });
266 setManagedRelaySession(registry, null);
265 setManagedRelaySession(registry, {
266 accountId: "account-1",
267 readClerkToken: () => Promise.resolve("refreshed-token"),
268 });
269 setManagedRelaySession(registry, {
270 accountId: "account-2",
271 readClerkToken: () => Promise.resolve("second-token"),
272 });
273 setManagedRelaySession(registry, null);
267274
268 expect(Array.from(await changes)).toEqual(["account-2", null]);
269 });
275 expect(Array.from(yield* Fiber.join(changes))).toEqual(["account-2", null]);
276 }),
277 );
270278
271279 it("shares one Clerk token read across concurrent relay list and status queries", async () => {
272280 const secondEnvironment = {
scripts/build-desktop-artifact.test.ts-1
......@@ -1,4 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off - packaged-archive fixtures compute the sidecar digest with the same Node primitive as the builder.
21import * as NodeCrypto from "node:crypto";
32
43import * as NodeServices from "@effect/platform-node/NodeServices";
vite.config.ts+66-3
......@@ -26,7 +26,6 @@ export default defineConfig({
2626 },
2727 fmt: {
2828 ignorePatterns: [
29 ".reference",
3029 ".repos/**",
3130 ".alchemy",
3231 "dist",
......@@ -37,7 +36,6 @@ export default defineConfig({
3736 "**/routeTree.gen.ts",
3837 "apps/mobile/android/**",
3938 "apps/mobile/ios/**",
40 "apps/web/src/lib/vendor/qrcodegen.ts",
4139 "apps/mobile/uniwind-types.d.ts",
4240 "*.icon/**",
4341 ],
......@@ -119,11 +117,76 @@ export default defineConfig({
119117 "t3code/no-global-process-runtime": "error",
120118 "t3code/no-inline-schema-compile": "warn",
121119 "t3code/no-manual-effect-runtime-in-tests": "error",
122 "t3code/no-mobile-uniwind-theme-escape-hatches": "error",
123120 "t3code/no-native-title-tooltip": "error",
124121 "t3code/namespace-node-imports": "error",
125122 },
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 ],
126188 options: {
189 reportUnusedDisableDirectives: "error",
127190 // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.
128191 typeAware: false,
129192 typeCheck: false,