From 194f838e7636f97739c440c7855aed195dbd7f52 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 19:40:26 -0700 Subject: [PATCH] chore: audit lint directives and move plugin allowlists into config (#9300) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/desktop/src/preview-pip-preload.ts | 1 - apps/mobile/metro.config.js | 3 +- .../src/MarkdownTextPrimitive.tsx | 18 ++-- apps/server/scripts/migrate-dev-db.ts | 1 - apps/server/scripts/t3-sqlite-state.ts | 1 - apps/server/src/assets/AttachmentUpload.ts | 1 - apps/server/src/http.ts | 3 +- .../src/relay/AgentAwarenessRelay.test.ts | 86 ++++++++----------- apps/server/src/server.test.ts | 16 ++-- apps/server/src/serviceLauncher.ts | 1 - .../settings/ThemeSearchSection.tsx | 13 ++- apps/web/src/hooks/useTheme.ts | 3 +- .../rules/no-global-process-runtime.ts | 21 +---- .../no-manual-effect-runtime-in-tests.test.ts | 31 +++++++ .../no-manual-effect-runtime-in-tests.ts | 66 ++++++-------- ...obile-uniwind-theme-escape-hatches.test.ts | 27 ++---- .../no-mobile-uniwind-theme-escape-hatches.ts | 71 +++++++-------- oxlint-plugin-t3code/test/utils.ts | 4 +- .../src/relay/managedRelayState.test.ts | 52 ++++++----- scripts/build-desktop-artifact.test.ts | 1 - vite.config.ts | 69 ++++++++++++++- 21 files changed, 258 insertions(+), 231 deletions(-) diff --git a/apps/desktop/src/preview-pip-preload.ts b/apps/desktop/src/preview-pip-preload.ts index 384c4129774fac5cb5b4ee84031719dc3099982c..6771eba8aafeb2cd14a01bf7fc20262a4300b69d 100644 --- a/apps/desktop/src/preview-pip-preload.ts +++ b/apps/desktop/src/preview-pip-preload.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 3791d347c62b3cd9be92210cf414c4570fff94d6..f8eda69b6fb7d7e68d9c56625e65e8cda5872611 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -36,8 +36,7 @@ config.resolver = { new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`), ], extraNodeModules: { - // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(config.resolver?.extraNodeModules ?? {}), + ...config.resolver?.extraNodeModules, shiki: mobileShikiRoot, "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 2cd54b5c1ef2edfb7ed539d57b9d7e58b034cb1e..36e87ee941589ca95384f5bf262747ecf5321cc5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -9,10 +9,10 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ StyleSheet.create({}), ]); -const textDefaults: TextProps = { +const textDefaults = { allowFontScaling: true, selectable: true, -}; +} satisfies TextProps; const useTextAncestorContext = () => React.useContext(TextAncestorContext); @@ -28,7 +28,11 @@ export type ContextMenuActionEvent = { nativeEvent: { target: number; actionIdentifier: string }; }; -export type MarkdownTextPrimitiveProps = TextProps & { +/** + * `onTextLayout` is not offered: the native view reports plain line strings + * while the React Native Text fallback reports measured `TextLayoutLine`s. + */ +export type MarkdownTextPrimitiveProps = Omit & { uiTextView?: boolean; contextMenuConfig?: string; onContextMenuAction?: (event: ContextMenuActionEvent) => void; @@ -75,16 +79,14 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr }); if (!isAncestor) { + // Press handlers are delivered by the text runs; the container never sees them. + const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest; return ( {nativeChildren} diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 5670f0d52b045bbeb93af95ebb13ca362c5d626a..fd1b74cb4740c8a75ca56d2f5cbf4555108ff5a2 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -24,7 +24,6 @@ * cursors never rewind. */ -// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index c34f4750c14cd731cd8b582ef67b7c815ea795ca..8423e7e6090f6accc552401d4c7d25c35b7a8bb0 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index ba3539a3df4004b9e6660fd9c14f9a766b6afedf..28b20c7ba2677d9a34515d798a6bcd81385e6bfc 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3bcca884107c75846dd7cd3cd789abfd734eddb5..ddc6d67e134c397bdbe66b823b51d658a26490c4 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -73,8 +73,7 @@ export function downloadContentDisposition(fileName?: string): string { return "attachment"; } // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates. - // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls. - const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_"); + const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_"); const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_"); const needsExtended = asciiFallback !== sanitized; const extendedName = encodeURIComponent(sanitized).replace( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 2465052a33c7b547bf39ac1c28a12e30d9049f83..b38861c9877e55e6f18d3a5d519e612fd8178534 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -358,58 +358,45 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ).toEqual([activeThreadId]); }); - it("signs the activity publish JWT and rejects tampering", async () => { - const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { - privateKeyEncoding: { format: "pem", type: "pkcs8" }, - publicKeyEncoding: { format: "pem", type: "spki" }, - }); - const payload = { - iss: "t3-env:env", - aud: "https://relay.example.test", - sub: "env", - jti: "nonce-1", - iat: 100, - exp: 200, - environmentId: state.environmentId, - threadId: state.threadId, - state, - } satisfies RelayAgentActivityPublishProofPayload; - const proof = await Effect.runPromise( - AgentAwarenessRelay.signRelayAgentActivityPublishProof({ + it.effect("signs the activity publish JWT and rejects tampering", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const payload = { + iss: "t3-env:env", + aud: "https://relay.example.test", + sub: "env", + jti: "nonce-1", + iat: 100, + exp: 200, + environmentId: state.environmentId, + threadId: state.threadId, + state, + } satisfies RelayAgentActivityPublishProofPayload; + const proof = yield* AgentAwarenessRelay.signRelayAgentActivityPublishProof({ privateKey: keyPair.privateKey, payload, - }), - ); - - await expect( - Effect.runPromise( + }); + const verify = (token: string) => verifyRelayJwt({ publicKey: keyPair.publicKey, - token: proof, + token, typ: RELAY_ACTIVITY_PUBLISH_TYP, issuer: "t3-env:env", audience: "https://relay.example.test", nowEpochSeconds: 150, - }), - ), - ).resolves.toMatchObject({ jti: "nonce-1", state }); - await expect( - Effect.runPromise( - verifyRelayJwt({ - publicKey: keyPair.publicKey, - token: (() => { - const [header, body, signature = ""] = proof.split("."); - const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; - return `${header}.${body}.${corruptedSignature}`; - })(), - typ: RELAY_ACTIVITY_PUBLISH_TYP, - issuer: "t3-env:env", - audience: "https://relay.example.test", - nowEpochSeconds: 150, - }), - ), - ).rejects.toBeDefined(); - }); + }); + + expect(yield* verify(proof)).toMatchObject({ jti: "nonce-1", state }); + + const [header, body, signature = ""] = proof.split("."); + const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; + const rejection = yield* Effect.flip(verify(`${header}.${body}.${corruptedSignature}`)); + expect(rejection).toBeDefined(); + }), + ); it.effect("keeps the orchestration listener armed until relay config is installed", () => Effect.scoped( @@ -557,10 +544,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Effect.scoped( Effect.gen(function* () { const originalFetch = globalThis.fetch; - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); - const fetchSeen = yield* Deferred.make(); + let resolveFetchSeen: (url: URL) => void = () => {}; + const fetchSeen = new Promise((resolve) => { + resolveFetchSeen = resolve; + }); const userSpans: Array = []; const productSpans: Array = []; const collectingTracer = (spans: Array) => @@ -648,7 +636,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ? input : (input as unknown as { readonly url: string }).url, ); - runFork(Deferred.succeed(fetchSeen, url)); + resolveFetchSeen(url); return Promise.resolve(Response.json({ ok: true, deliveries: [] })); }) as unknown as typeof fetch; yield* Effect.addFinalizer(() => @@ -707,7 +695,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { occurredAt: now, } as unknown as OrchestrationEvent); - const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); + const url = yield* Effect.promise(() => fetchSeen).pipe(Effect.timeout("2 seconds")); expect(url.origin).toBe("https://transport.example.test"); expect(productSpans).toContain("makePublishProof"); expect(userSpans).not.toContain("makePublishProof"); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d3e94e4eea440602a4f2b5678e036251ec8bcb81..cfa0bf3dbeecf408bccd16dce4c3a0df69de4542 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -56,7 +56,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; @@ -398,7 +397,9 @@ const makeBrowserOtlpPayload = (spanName: string) => ({ close }) => Effect.promise(close), ); - const runtime = ManagedRuntime.make( + // The exporter's batch fiber is forked while the layer builds and ticks on + // a wall-clock interval, so the whole tracer runs on the live clock. + yield* Layer.build( OtlpTracer.layer({ url: collector.url, exportInterval: "10 millis", @@ -411,14 +412,13 @@ const makeBrowserOtlpPayload = (spanName: string) => }, }, }).pipe(Layer.provide(browserOtlpTracingLayer)), + ).pipe( + Effect.flatMap((tracing) => + Effect.void.pipe(Effect.withSpan(spanName), Effect.provideContext(tracing)), + ), + TestClock.withLive, ); - try { - yield* Effect.promise(() => runtime.runPromise(Effect.void.pipe(Effect.withSpan(spanName)))); - } finally { - yield* Effect.promise(() => runtime.dispose()); - } - const request = yield* Effect.raceFirst( Effect.promise(() => collector.firstRequest).pipe(Effect.orDie), Effect.sleep(Duration.seconds(1)).pipe( diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 68f3c346759df70d11344de6438ab191ca9a0aa1..b99f90e0fdf5b054bbc23782cc4f615145557f12 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalDate:off // @effect-diagnostics globalTimers:off // This file is shipped as a standalone bundle and copied to a stable path by // `t3 service update`. Keep runtime imports limited to Node built-ins. diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index 14919e09ba22871739eca56fb505f48754eefac6..b270bf7b8e4d23ec3e16333a5edd3a2963fef57d 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -204,13 +204,12 @@ export function ThemeSearchSection({ return; } void runSearch(debouncedQuery); - // `installingId` and `sortBy` are deliberately not dependencies: the - // guards above read the current values from the fresh render closure. An - // install finishing reruns the search only when the query or sort changed - // while it was in flight (checked via lastSearchKeyRef, recorded only - // once a search succeeds), so the install error the user needs to see is - // preserved across that rerun. - // eslint-disable-next-line react-hooks/exhaustive-deps + // `sortBy` is deliberately not a direct dependency: the guards above read + // the current value from the fresh render closure. An install finishing + // reruns the search only when the query or sort changed while it was in + // flight (checked via lastSearchKeyRef, recorded only once a search + // succeeds), so the install error the user needs to see is preserved + // across that rerun. }, [open, query, debouncedQuery, installingId, runSearch]); const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index e729335f96ba1c0ca92a87d99c45a4a96328d73d..726a03dac7b8819fb4c0fa962a3e856fff97349f 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -356,8 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview syncDesktopTheme(theme, followSystem, appearanceMode); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal - // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; + void document.documentElement.offsetHeight; requestAnimationFrame(() => { document.documentElement.classList.remove("no-transitions"); }); diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts index e364d29040f2ffe1595e74ae15fe07af7a1a5a04..f122d16db29ac68b5ef45758e9a4454c88ce2627 100644 --- a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts @@ -4,23 +4,8 @@ import * as Option from "effect/Option"; import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; const RUNTIME_PROPERTIES = new Set(["platform", "arch"]); -const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts"; const NODE_OS_MODULES = new Set(["node:os", "os"]); -const normalizePath = (path: string) => path.replaceAll("\\", "/"); - -const toRepoPath = (filename: string, cwd: string) => { - const normalizedFilename = normalizePath(filename); - const normalizedCwd = normalizePath(cwd).replace(/\/+$/u, ""); - const prefix = `${normalizedCwd}/`; - return normalizedFilename.startsWith(prefix) - ? normalizedFilename.slice(prefix.length) - : normalizedFilename; -}; - -const isHostProcessReferenceFile = (filename: string, cwd: string) => - toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE; - const isGlobalProcessObject = (node: unknown): boolean => { const expression = unwrapExpression(node); if (isIdentifier(expression, "process")) return true; @@ -48,7 +33,7 @@ export default defineRule({ type: "problem", docs: { description: - "Disallow direct host runtime platform/architecture reads outside the shared host process references.", + "Disallow direct host runtime platform/architecture reads; the shared host process references are exempted in the lint config.", }, }, createOnce(context) { @@ -117,8 +102,6 @@ export default defineRule({ before: resetBindings, ImportDeclaration: trackImportDeclaration, MemberExpression(node) { - if (isHostProcessReferenceFile(context.filename, context.cwd)) return; - const property = getPropertyName(node.property); if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return; if (!isGlobalProcessObject(node.object)) return; @@ -129,8 +112,6 @@ export default defineRule({ }); }, CallExpression(node) { - if (isHostProcessReferenceFile(context.filename, context.cwd)) return; - const property = getNodeOsRuntimeCall(node.callee); if (Option.isNone(property)) return; diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts index bd8c97f5fa490d044892e6f6f54f671fd059b34d..6864699a54c7da6b6bdf3a9f9bb80f987bd81231 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts @@ -71,3 +71,34 @@ productionRule.valid( export const main = () => Effect.runPromise(Effect.void); `, ); + +const legacyRule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests", { + filename: "legacy.test.ts", + ruleOptions: [{ maxOccurrences: 2 }], +}); + +describe("t3code/no-manual-effect-runtime-in-tests with maxOccurrences", () => { + legacyRule.valid( + "allows occurrences up to the ceiling", + ` + import * as Effect from "effect/Effect"; + + test("first", () => Effect.runSync(Effect.void)); + test("second", () => Effect.runSync(Effect.void)); + `, + ); + + legacyRule.invalid( + "reports occurrences beyond the ceiling", + ` + import * as Effect from "effect/Effect"; + + test("first", () => Effect.runSync(Effect.void)); + test("second", () => Effect.runSync(Effect.void)); + test("third", () => Effect.runSync(Effect.void)); + `, + (output) => { + assert.equal(output.match(/no-manual-effect-runtime-in-tests/g)?.length, 1); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index e6eff1e5c215a55f38a14b4b8ad590385f8fff55..ae90cb2f29bdf219ab591a517f8f9f2640473649 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -19,44 +19,17 @@ const EFFECT_RUNTIME_METHODS = new Set([ "runSyncWith", ]); -// Existing manual runners are tracked as debt. The rule permits no net-new -// occurrences in these files, while unlisted test files must have zero. -const LEGACY_BASELINE = new Map([ - ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1], - ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2], - ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2], - ["apps/server/src/orchestration/commandInvariants.test.ts", 6], - ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42], - ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5], - ["apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts", 4], - ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70], - ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31], - ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2], - ["apps/server/src/orchestration/projector.test.ts", 20], - ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4], - ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1], - ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2], - ["apps/server/src/provider/Layers/CodexAdapter.test.ts", 1], - ["apps/server/src/provider/Layers/CodexSessionRuntime.test.ts", 5], - ["apps/server/src/provider/Layers/CursorAdapter.test.ts", 1], - ["apps/server/src/provider/Layers/CursorProvider.test.ts", 4], - ["apps/server/src/provider/Layers/ProviderService.test.ts", 2], - ["apps/server/src/provider/Layers/ProviderSessionReaper.test.ts", 21], - ["apps/server/src/relay/AgentAwarenessRelay.test.ts", 4], - ["apps/server/src/server.test.ts", 1], - ["apps/web/src/cloud/dpop.test.ts", 2], - ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], - ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], - ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1], - ["packages/client-runtime/src/wsTransport.test.ts", 2], -]); - -const baselineFor = (filename: string): number => { - const normalized = filename.replaceAll("\\", "/"); - for (const [suffix, count] of LEGACY_BASELINE) { - if (normalized.endsWith(suffix)) return count; - } - return 0; +// Existing manual runners are tracked as debt through the `maxOccurrences` +// option, set per file in the lint config. The rule permits no net-new +// occurrences in those files, while every other test file must have zero. +const readMaxOccurrences = (options: ReadonlyArray): number => { + const [first] = options; + return typeof first === "object" && + first !== null && + "maxOccurrences" in first && + typeof first.maxOccurrences === "number" + ? first.maxOccurrences + : 0; }; const manualRunnerName = (callee: unknown): Option.Option => { @@ -87,11 +60,26 @@ export default defineRule({ description: "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.", }, + schema: [ + { + type: "object", + properties: { + maxOccurrences: { + type: "integer", + minimum: 0, + description: + "Legacy debt ceiling for this file: occurrences beyond this count are reported.", + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ maxOccurrences: 0 }], }, create(context) { if (!TEST_FILE_PATTERN.test(context.filename)) return {}; - const allowedCount = baselineFor(context.filename); + const allowedCount = readMaxOccurrences(context.options); let occurrenceCount = 0; return { diff --git a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts index 67500e74709859f6b12bfcf04619f642ac2f57c3..9ba9fce14bfe39ed75063f1458e9a69b3bc055dc 100644 --- a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts +++ b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts @@ -7,15 +7,11 @@ const guardedMobileFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-them }); const reviewedInteropFile = createOxlintRuleHarness( "t3code/no-mobile-uniwind-theme-escape-hatches", - { filename: "apps/mobile/src/features/home/HomeHeader.tsx" }, + { + filename: "apps/mobile/src/features/home/HomeHeader.tsx", + ruleOptions: [{ allowUniwindTheme: true }], + }, ); -const gitOverlayInteropFile = createOxlintRuleHarness( - "t3code/no-mobile-uniwind-theme-escape-hatches", - { filename: "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx" }, -); -const webFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-theme-escape-hatches", { - filename: "apps/web/src/ThemeSurface.tsx", -}); describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => { guardedMobileFile.valid( @@ -93,18 +89,9 @@ describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => { `, ); - gitOverlayInteropFile.valid( - "allows the native liquid-glass theme boundary", - ` - import { useUniwindTheme } from "../../lib/useUniwindTheme"; - - export const tint = useUniwindTheme()["--color-glass-surface"]; - `, - ); - - webFile.valid( - "does not impose the mobile custom-theme policy on web code", - `const surface =
;`, + reviewedInteropFile.invalid( + "still reports appearance variants in reviewed interop boundaries", + `const surface = ;`, ); guardedMobileFile.invalid( diff --git a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts index 5e6a7111e816dbfb916d1a16d49fd6cf3b30cb0b..f524709c36ef7c426410ba0bc936c4352554d9b2 100644 --- a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts +++ b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts @@ -3,44 +3,9 @@ import * as Option from "effect/Option"; import { getPropertyName, unwrapExpression } from "../utils.ts"; -const MOBILE_SOURCE_MARKER = "/apps/mobile/src/"; const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u; const APPEARANCE_VARIANT_MESSAGE = "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token."; -const THEME_INTEROP_ALLOWLIST = new Set([ - "features/archive/ArchivedThreadsScreen.tsx", - "features/connection/ConnectionsNewRouteScreen.tsx", - "features/files/FileMarkdownPreview.tsx", - "features/files/SourceFileSurface.tsx", - "features/files/ThreadFilesRouteScreen.tsx", - "features/files/thread-file-navigator-pane.tsx", - "features/home/HomeHeader.tsx", - "features/review/ReviewSheet.tsx", - "features/review/useNativeReviewDiffBridge.ts", - "features/settings/SettingsEnvironmentsRouteScreen.tsx", - "features/settings/appearance/components/AppearancePreviews.tsx", - "features/settings/appearance/components/FontSizeSliderRow.tsx", - "features/threads/GitActionProgressOverlay.tsx", - "features/threads/NewTaskContextPickerScreens.tsx", - "features/threads/NewTaskDraftScreen.tsx", - "features/threads/ThreadComposer.tsx", - "features/threads/ThreadFeed.tsx", - "features/threads/ThreadSettingsSheet.tsx", - "features/threads/git/GitOverviewSheet.tsx", - "features/threads/thread-list-items.tsx", - "features/threads/thread-list-v2-items.tsx", - "lib/useMobileNavigationTheme.ts", - "native/T3ComposerEditor.ios.tsx", - "native/T3ComposerEditor.native.tsx", -]); - -const mobileSourcePath = (filename: string): string | undefined => { - const normalized = `/${filename.replaceAll("\\", "/")}`; - const markerIndex = normalized.lastIndexOf(MOBILE_SOURCE_MARKER); - return markerIndex === -1 - ? undefined - : normalized.slice(markerIndex + MOBILE_SOURCE_MARKER.length); -}; const literalStringValue = (node: unknown): Option.Option => { if (typeof node !== "object" || node === null) return Option.none(); @@ -54,18 +19,40 @@ const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.t const importsModule = (source: string, modulePath: string): boolean => source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath); +const readAllowUniwindTheme = (options: ReadonlyArray): boolean => { + const [first] = options; + return ( + typeof first === "object" && + first !== null && + "allowUniwindTheme" in first && + first.allowUniwindTheme === true + ); +}; + export default defineRule({ meta: { type: "problem", docs: { description: - "Keep mobile theme styling on semantic Uniwind classes and reviewed native interop boundaries.", + "Keep mobile theme styling on semantic Uniwind classes. Scope the rule to mobile sources and exempt reviewed native interop boundaries in the lint config.", }, + schema: [ + { + type: "object", + properties: { + allowUniwindTheme: { + type: "boolean", + description: + "Permit useUniwindTheme in a reviewed native/third-party interop boundary that cannot consume a className.", + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowUniwindTheme: false }], }, create(context) { - const sourcePath = mobileSourcePath(context.filename); - if (sourcePath === undefined) return {}; - + const allowUniwindTheme = readAllowUniwindTheme(context.options); const uniwindNamespaces = new Set(); const resolveVariable = (node: unknown): Variable | undefined => { @@ -131,13 +118,13 @@ export default defineRule({ if ( !isTypeOnly && - importsModule(source.value, "/useUniwindTheme") && - !THEME_INTEROP_ALLOWLIST.has(sourcePath) + !allowUniwindTheme && + importsModule(source.value, "/useUniwindTheme") ) { context.report({ node: specifier, message: - "Use className for theme styling, or review and add this native/third-party interop boundary to the lint allowlist.", + "Use className for theme styling, or review this native/third-party interop boundary and enable allowUniwindTheme for it in the lint config.", }); } } diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index 704c3436ce131a7237ad625b0cc815bafeaf8438..dc80ea3451f405b9d38704907c216e9deeb9d5c5 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -61,6 +61,8 @@ interface RuleHarness { interface RuleHarnessOptions { readonly filename?: string; + /** Rule options, as they would appear after the severity in the lint config. */ + readonly ruleOptions?: ReadonlyArray; } const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => @@ -111,7 +113,7 @@ export const createOxlintRuleHarness = ( configPath, yield* encodeOxlintConfig({ jsPlugins: [{ name: "t3code", specifier: pluginPath }], - rules: { [ruleName]: "error" }, + rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] }, }), ); yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 0588da342066e728bd310ff85f48f5882c1756f5..8c93ec136d60466ad1aa50d97a1398b7b8b27ec5 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -243,30 +243,38 @@ describe("createManagedRelayQueryManager", () => { }), ); - it("emits credential changes only when the managed relay account changes", async () => { - setManagedRelaySession(registry, { - accountId: "account-1", - readClerkToken: () => Promise.resolve("first-token"), - }); - const changes = Effect.runPromise( - managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect), - ); - await vi.waitFor(() => { - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0); - }); + it.effect("emits credential changes only when the managed relay account changes", () => + Effect.gen(function* () { + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("first-token"), + }); + const changes = yield* managedRelayAccountChanges(registry).pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan( + 0, + ); + }), + ); - setManagedRelaySession(registry, { - accountId: "account-1", - readClerkToken: () => Promise.resolve("refreshed-token"), - }); - setManagedRelaySession(registry, { - accountId: "account-2", - readClerkToken: () => Promise.resolve("second-token"), - }); - setManagedRelaySession(registry, null); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("refreshed-token"), + }); + setManagedRelaySession(registry, { + accountId: "account-2", + readClerkToken: () => Promise.resolve("second-token"), + }); + setManagedRelaySession(registry, null); - expect(Array.from(await changes)).toEqual(["account-2", null]); - }); + expect(Array.from(yield* Fiber.join(changes))).toEqual(["account-2", null]); + }), + ); it("shares one Clerk token read across concurrent relay list and status queries", async () => { const secondEnvironment = { diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d2ca3c601bfa399210c67d948474dbe60df6deeb..19979c330dc0172daaa39a48194c88900803510f 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off - packaged-archive fixtures compute the sidecar digest with the same Node primitive as the builder. import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/vite.config.ts b/vite.config.ts index 343adcbaa5aab8271bd2c23447e6be190603b075..c616998d529733f7fc6438de248f401774cf37f8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ }, fmt: { ignorePatterns: [ - ".reference", ".repos/**", ".alchemy", "dist", @@ -37,7 +36,6 @@ export default defineConfig({ "**/routeTree.gen.ts", "apps/mobile/android/**", "apps/mobile/ios/**", - "apps/web/src/lib/vendor/qrcodegen.ts", "apps/mobile/uniwind-types.d.ts", "*.icon/**", ], @@ -119,11 +117,76 @@ export default defineConfig({ "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", - "t3code/no-mobile-uniwind-theme-escape-hatches": "error", "t3code/no-native-title-tooltip": "error", "t3code/namespace-node-imports": "error", }, + overrides: [ + { + // The one place that reads the host platform to seed the injected references. + files: ["packages/shared/src/hostProcess.ts"], + rules: { "t3code/no-global-process-runtime": "off" }, + }, + { + files: ["apps/mobile/src/**"], + rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, + }, + { + // Reviewed native and third-party interop boundaries that cannot consume a className. + files: [ + "apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx", + "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", + "apps/mobile/src/features/files/FileMarkdownPreview.tsx", + "apps/mobile/src/features/files/SourceFileSurface.tsx", + "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", + "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", + "apps/mobile/src/features/home/HomeHeader.tsx", + "apps/mobile/src/features/review/ReviewSheet.tsx", + "apps/mobile/src/features/review/useNativeReviewDiffBridge.ts", + "apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx", + "apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx", + "apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx", + "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx", + "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx", + "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx", + "apps/mobile/src/features/threads/ThreadComposer.tsx", + "apps/mobile/src/features/threads/ThreadFeed.tsx", + "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx", + "apps/mobile/src/features/threads/git/GitOverviewSheet.tsx", + "apps/mobile/src/features/threads/thread-list-items.tsx", + "apps/mobile/src/features/threads/thread-list-v2-items.tsx", + "apps/mobile/src/lib/useMobileNavigationTheme.ts", + "apps/mobile/src/native/T3ComposerEditor.ios.tsx", + "apps/mobile/src/native/T3ComposerEditor.native.tsx", + ], + rules: { + "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], + }, + }, + // Legacy manual Effect runners tracked as debt: no net-new occurrences. + // Lower a ceiling when you migrate a file, and delete its entry at zero. + ...Object.entries({ + "apps/server/src/orchestration/Layers/CheckpointReactor.test.ts": 42, + "apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts": 5, + "apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts": 4, + "apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts": 66, + "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 29, + "apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts": 2, + "apps/server/src/orchestration/commandInvariants.test.ts": 5, + "apps/server/src/orchestration/projector.test.ts": 20, + "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1, + "apps/server/src/provider/Layers/CodexSessionRuntime.test.ts": 5, + "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1, + "apps/server/src/provider/Layers/CursorProvider.test.ts": 1, + "apps/server/src/provider/Layers/ProviderService.test.ts": 2, + "apps/server/src/provider/Layers/ProviderSessionReaper.test.ts": 12, + "apps/server/src/provider/acp/CursorAcpSupport.test.ts": 1, + }).map(([file, maxOccurrences]) => { + const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }]; + return { files: [file], rules: { "t3code/no-manual-effect-runtime-in-tests": rule } }; + }), + ], options: { + reportUnusedDisableDirectives: "error", // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. typeAware: false, typeCheck: false, -- 2.54.0