t3-code-android-nightly/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts
Julius Marminge 194f838e76
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>
2026-09-02 19:40:26 -07:00

212 lines
7.6 KiB
TypeScript

import { defineRule, type Variable } from "@oxlint/plugins";
import * as Option from "effect/Option";
import { getPropertyName, unwrapExpression } from "../utils.ts";
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 literalStringValue = (node: unknown): Option.Option<string> => {
if (typeof node !== "object" || node === null) return Option.none();
if (!("type" in node) || node.type !== "Literal") return Option.none();
if (!("value" in node) || typeof node.value !== "string") return Option.none();
return Option.some(node.value);
};
const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.test(value);
const importsModule = (source: string, modulePath: string): boolean =>
source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath);
const readAllowUniwindTheme = (options: ReadonlyArray<unknown>): 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. 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 allowUniwindTheme = readAllowUniwindTheme(context.options);
const uniwindNamespaces = new Set<Variable>();
const resolveVariable = (node: unknown): Variable | undefined => {
const identifier = unwrapExpression(node);
if (Option.isNone(identifier) || identifier.value.type !== "Identifier") return undefined;
let scope = context.sourceCode.getScope(identifier.value);
while (true) {
const variable = scope.set.get(identifier.value.name);
if (variable !== undefined || scope.upper === null) return variable;
scope = scope.upper;
}
};
return {
ImportDeclaration(node) {
const source = literalStringValue(node.source);
if (Option.isNone(source)) return;
const declaredVariables = context.sourceCode.getDeclaredVariables(node);
for (const specifier of node.specifiers) {
const local = unwrapExpression(specifier.local);
const importedName =
specifier.type === "ImportSpecifier"
? getPropertyName(specifier.imported)
: Option.none();
const isTypeOnly =
node.importKind === "type" ||
(specifier.type === "ImportSpecifier" && specifier.importKind === "type");
if (
!isTypeOnly &&
specifier.type === "ImportNamespaceSpecifier" &&
Option.isSome(local) &&
local.value.type === "Identifier"
) {
const localName = local.value.name;
const variable = declaredVariables.find((candidate) => candidate.name === localName);
if (source.value === "uniwind" && variable !== undefined) {
uniwindNamespaces.add(variable);
}
}
if (
!isTypeOnly &&
source.value === "uniwind" &&
Option.isSome(importedName) &&
importedName.value === "useCSSVariable"
) {
context.report({
node: specifier,
message:
"Use a semantic className instead of useCSSVariable; it adds a React theme subscription.",
});
}
if (!isTypeOnly && importsModule(source.value, "/useThemeColor")) {
context.report({
node: specifier,
message: "useThemeColor was replaced by semantic Uniwind classes.",
});
}
if (
!isTypeOnly &&
!allowUniwindTheme &&
importsModule(source.value, "/useUniwindTheme")
) {
context.report({
node: specifier,
message:
"Use className for theme styling, or review this native/third-party interop boundary and enable allowUniwindTheme for it in the lint config.",
});
}
}
},
MemberExpression(node) {
const object = unwrapExpression(node.object);
if (Option.isNone(object) || object.value.type !== "Identifier") return;
const property = getPropertyName(node.property);
if (Option.isNone(property)) return;
const namespace = resolveVariable(object.value);
if (
namespace !== undefined &&
uniwindNamespaces.has(namespace) &&
property.value === "useCSSVariable"
) {
context.report({
node,
message:
"Use a semantic className instead of useCSSVariable; it adds a React theme subscription.",
});
}
},
VariableDeclarator(node) {
const initializer = unwrapExpression(node.init);
const binding = unwrapExpression(node.id);
if (
Option.isNone(initializer) ||
initializer.value.type !== "Identifier" ||
Option.isNone(binding)
) {
return;
}
const namespace = resolveVariable(initializer.value);
if (namespace === undefined || !uniwindNamespaces.has(namespace)) return;
if (binding.value.type === "Identifier") {
const bindingName = binding.value.name;
const variable = context.sourceCode
.getDeclaredVariables(node)
.find((candidate) => candidate.name === bindingName);
if (variable !== undefined) uniwindNamespaces.add(variable);
return;
}
if (binding.value.type !== "ObjectPattern") return;
const declaredVariables = context.sourceCode.getDeclaredVariables(node);
for (const propertyNode of binding.value.properties) {
if (propertyNode.type === "RestElement") {
const restBinding = unwrapExpression(propertyNode.argument);
if (Option.isNone(restBinding) || restBinding.value.type !== "Identifier") continue;
const restName = restBinding.value.name;
const variable = declaredVariables.find((candidate) => candidate.name === restName);
if (variable !== undefined) uniwindNamespaces.add(variable);
continue;
}
if (propertyNode.type !== "Property") continue;
const property = getPropertyName(propertyNode.key);
if (Option.isNone(property)) continue;
if (property.value === "useCSSVariable") {
context.report({
node: propertyNode,
message:
"Use a semantic className instead of useCSSVariable; it adds a React theme subscription.",
});
}
}
},
Literal(node) {
if (typeof node.value !== "string" || !reportsAppearanceVariant(node.value)) return;
context.report({ node, message: APPEARANCE_VARIANT_MESSAGE });
},
TemplateElement(node) {
if (!reportsAppearanceVariant(node.value.cooked ?? "")) return;
context.report({ node, message: APPEARANCE_VARIANT_MESSAGE });
},
};
},
});