@commonplaces/oxlint-plugin 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { Record } from "effect";
2
+ import conventionPlugin from "./conventions.js";
3
+ import webPlugin from "./web.js";
1
4
  /** General code checks, with no project-specific file names or settings. */
2
5
  export const general = {
3
6
  jsPlugins: ["@commonplaces/oxlint-plugin/anti-slop", "@commonplaces/oxlint-plugin/package"],
@@ -35,6 +38,7 @@ export const effect = {
35
38
  "effect/no-instanceof": "error",
36
39
  "effect/no-manual-tag": "error",
37
40
  "effect/no-raw-fetch": "error",
41
+ "effect/require-service-operation-instrumentation": "error",
38
42
  "strict-effect/static-layers": "error",
39
43
  "strict-effect/subprocess-pipe-capture": "error",
40
44
  "strict-effect/no-runtime-type-assertions": "error",
@@ -52,6 +56,42 @@ export const effect = {
52
56
  "anti-slop-effect/no-service-constructor-imports": "error",
53
57
  },
54
58
  };
59
+ /** Optional strict coding conventions; boundary permissions remain project facts. */
60
+ export const conventions = {
61
+ jsPlugins: ["@commonplaces/oxlint-plugin/conventions"],
62
+ rules: Record.mapKeys(Record.map(Record.filter(conventionPlugin.rules, (_, name) => name !== "no-schema-decoder-outside-ingress" &&
63
+ name !== "no-schema-encoder-outside-egress" &&
64
+ name !== "no-sync-decoder-outside-consumer" &&
65
+ name !== "browser-runtime-ownership" &&
66
+ name !== "native-http-client-ownership" &&
67
+ name !== "bounded-build-memo"), () => "error"), (name) => `effect-conventions/${name}`),
68
+ };
69
+ /** Optional React/TanStack, MDX, semantic typography and route conventions. */
70
+ export const web = {
71
+ plugins: ["react", "react-perf", "jsx-a11y"],
72
+ jsPlugins: ["@commonplaces/oxlint-plugin/web"],
73
+ rules: Record.mapKeys(Record.map(webPlugin.rules, () => "error"), (name) => `web/${name}`),
74
+ };
75
+ /** Native type-aware checks. Run Oxlint with --type-aware. */
76
+ export const typescript = {
77
+ plugins: ["typescript", "unicorn", "import", "vitest"],
78
+ categories: { correctness: "error" },
79
+ rules: {
80
+ "no-ternary": "error",
81
+ "typescript/consistent-type-assertions": ["error", { assertionStyle: "never" }],
82
+ "typescript/no-explicit-any": "error",
83
+ "typescript/no-non-null-assertion": "error",
84
+ "typescript/await-thenable": "error",
85
+ "typescript/no-floating-promises": "error",
86
+ "typescript/no-misused-promises": "error",
87
+ "typescript/no-unsafe-argument": "error",
88
+ "typescript/no-unsafe-assignment": "error",
89
+ "typescript/no-unsafe-call": "error",
90
+ "typescript/no-unsafe-member-access": "error",
91
+ "typescript/no-unsafe-return": "error",
92
+ "typescript/no-unsafe-type-assertion": "error",
93
+ },
94
+ };
55
95
  /** Native Alchemy configuration and secret-source checks. */
56
96
  export const alchemy = {
57
97
  jsPlugins: ["@commonplaces/oxlint-plugin/alchemy", "@commonplaces/oxlint-plugin/strict-effect"],
@@ -62,3 +102,7 @@ export const alchemy = {
62
102
  "strict-effect/native-cloudflare-cache": "error",
63
103
  },
64
104
  };
105
+ /** Complete strict Effect application profile; exact project owners stay local. */
106
+ export const strict = {
107
+ extends: [general, effect, alchemy, conventions, web, typescript],
108
+ };
@@ -1,5 +1,11 @@
1
1
  import type { Context, ESTree, Variable } from "@oxlint/plugins";
2
2
  import * as Option from "effect/Option";
3
+ interface Reference {
4
+ readonly module: string;
5
+ readonly name: Option.Option<string>;
6
+ }
3
7
  export declare const constant: (variable: Variable) => Option.Option<ESTree.VariableDeclarator>;
8
+ export declare const referenceOf: (context: Context, node: ESTree.Node, visited?: readonly ESTree.Node[]) => Option.Option<Reference>;
4
9
  export declare const valueOf: (context: Context, node: ESTree.Node, visited?: readonly ESTree.Node[]) => Option.Option<ESTree.Node>;
5
10
  export declare const operation: (context: Context, node: ESTree.Node, module: string, name: string) => boolean;
11
+ export {};
@@ -9,8 +9,12 @@ export const constant = (variable) => Array.head(variable.defs).pipe(Option.filt
9
9
  const member = (reference, name) => {
10
10
  if (Option.isSome(reference.name))
11
11
  return Option.none();
12
+ if (reference.module === "@effect/atom-react" && name === "Atom") {
13
+ return Option.some({ module: "effect/reactivity/Atom", name: Option.none() });
14
+ }
12
15
  return reference.module === "alchemy" ||
13
16
  reference.module === "effect" ||
17
+ Array.contains(["effect/reactivity", "effect/http", "effect/http-api", "effect/rpc", "effect/unstable/http"], reference.module) ||
14
18
  (reference.module === "effect/process" &&
15
19
  (name === "ChildProcess" || name === "ChildProcessSpawner"))
16
20
  ? Option.some({ module: `${reference.module}/${name}`, name: Option.none() })
@@ -18,11 +22,14 @@ const member = (reference, name) => {
18
22
  };
19
23
  // Only real imports and immutable local aliases count. The nearest declaration
20
24
  // prevents a shadowed parameter from borrowing an outer import's identity.
21
- const referenceOf = (context, node, visited = []) => {
25
+ export const referenceOf = (context, node, visited = []) => {
22
26
  if (Array.some(visited, (previous) => previous === node))
23
27
  return Option.none();
24
28
  const next = [...visited, node];
25
- if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
29
+ if (node.type === "TSAsExpression" ||
30
+ node.type === "TSSatisfiesExpression" ||
31
+ node.type === "TSInstantiationExpression" ||
32
+ node.type === "ChainExpression") {
26
33
  return referenceOf(context, node.expression, next);
27
34
  }
28
35
  if (node.type === "MemberExpression") {
@@ -40,11 +47,6 @@ const referenceOf = (context, node, visited = []) => {
40
47
  definition.parent?.type === "ImportDeclaration" &&
41
48
  definition.parent.importKind !== "type") {
42
49
  const module = definition.parent.source.value;
43
- if (module !== "alchemy" &&
44
- !module.startsWith("alchemy/") &&
45
- module !== "effect" &&
46
- !module.startsWith("effect/"))
47
- return Option.none();
48
50
  const reference = { module, name: Option.none() };
49
51
  if (definition.node.type === "ImportNamespaceSpecifier")
50
52
  return Option.some(reference);
package/dist/package.js CHANGED
@@ -7,7 +7,7 @@ const resolveRelative = (fileName, specifier) => Array.join(Array.reduce(specifi
7
7
  : segment === "." || segment.length === 0
8
8
  ? segments
9
9
  : [...segments, segment]), "/");
10
- const workspaceSourceRoot = (fileName) => Option.fromNullishOr(fileName.match(/\/(?:apps|packages)\/(?:[^/]+\/)+?src(?:\/|$)/u)).pipe(Option.flatMap(Array.head), Option.map((root) => root.replace(/\/$/u, "")));
10
+ const workspaceSourceRoot = (fileName) => Option.fromNullishOr(fileName.match(/\/(?:apps|packages|content)\/[^/]+(?:\/|$)/u)).pipe(Option.flatMap(Array.head), Option.map((root) => root.replace(/\/$/u, "")));
11
11
  const isPrivatePackageAlias = (specifier) => /^@[^/]+\/[^/]+\/src(?:\/|$)/u.test(specifier);
12
12
  const isCrossWorkspaceSourcePath = (fileName, specifier) => {
13
13
  if (!specifier.startsWith(".")) {
@@ -0,0 +1 @@
1
+ export declare const requireServiceOperationInstrumentation: import("@oxlint/plugins").Rule;
@@ -0,0 +1,238 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { Array, Option, Schema } from "effect";
3
+ import { constant, operation, referenceOf, valueOf } from "./native-reference.js";
4
+ import { childNodes, propertyName, variableInScope } from "./syntax.js";
5
+ const Options = Schema.Struct({
6
+ serviceNames: Schema.optionalKey(Schema.NonEmptyArray(Schema.String)),
7
+ operationAdapters: Schema.optionalKey(Schema.Array(Schema.Struct({
8
+ module: Schema.NonEmptyString,
9
+ file: Schema.NonEmptyString.check(Schema.isPattern(/^(?!\/)(?!.*(?:\*|\\|(?:^|\/)\.\.?\/)).+\.tsx?$/u)),
10
+ exports: Schema.NonEmptyArray(Schema.NonEmptyString),
11
+ operation: Schema.NonEmptyString,
12
+ }))),
13
+ });
14
+ const adapted = (context, node, name, adapters) => {
15
+ const value = valueOf(context, node);
16
+ if (Option.isNone(value) || value.value.type !== "CallExpression")
17
+ return false;
18
+ return referenceOf(context, value.value.callee).pipe(Option.exists((reference) => Array.some(adapters, (adapter) => {
19
+ const relative = reference.module.startsWith(".")
20
+ ? Array.join(Array.reduce(reference.module.split("/"), Array.dropRight(context.filename.replaceAll("\\", "/").split("/"), 1), (parts, segment) => segment === ".."
21
+ ? Array.dropRight(parts, 1)
22
+ : segment === "."
23
+ ? parts
24
+ : [...parts, segment]), "/")
25
+ : "";
26
+ const source = (path) => path.replace(/\.[cm]?[jt]sx?$/u, "");
27
+ return (adapter.operation === name &&
28
+ Option.exists(reference.name, (export_) => Array.contains(adapter.exports, export_)) &&
29
+ (reference.module === adapter.module ||
30
+ source(relative) === source(`${context.cwd}/${adapter.file}`)));
31
+ })));
32
+ };
33
+ const serviceClass = (context, node) => {
34
+ const containsService = (value) => (value.type === "CallExpression" &&
35
+ operation(context, value.callee, "effect/Context", "Service")) ||
36
+ Array.some(childNodes(context, value), containsService);
37
+ return Option.fromNullishOr(node.superClass).pipe(Option.exists(containsService));
38
+ };
39
+ // Imported names preserve the contract's canonical name even when locally aliased.
40
+ // Local objects and shadowed parameters cannot borrow an imported service name.
41
+ const serviceOf = (context, node, visited = []) => {
42
+ if (Array.some(visited, (previous) => previous === node))
43
+ return Option.none();
44
+ const next = [...visited, node];
45
+ const imported = referenceOf(context, node).pipe(Option.filter((reference) => !/^(?:effect|alchemy)(?:\/|$)/u.test(reference.module)), Option.flatMap((reference) => reference.name), Option.filter((name) => /^[A-Z]/u.test(name)), Option.map((name) => ({ name, declaration: Option.none() })));
46
+ if (Option.isSome(imported))
47
+ return imported;
48
+ if (node.type !== "Identifier")
49
+ return Option.none();
50
+ return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap((variable) => Array.head(variable.defs).pipe(Option.flatMap((definition) => {
51
+ if (definition.node.type === "ClassDeclaration" &&
52
+ serviceClass(context, definition.node)) {
53
+ return Option.some({ name: node.name, declaration: Option.some(definition.node) });
54
+ }
55
+ return constant(variable).pipe(Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.flatMap((value) => serviceOf(context, value, next)));
56
+ }))));
57
+ };
58
+ // Local contracts supply positive evidence for data/Stream members. No property
59
+ // name, file suffix or service name is exempted from operation checks.
60
+ const contractFields = (context, service) => {
61
+ const fields = (node) => {
62
+ if (node.type === "CallExpression" &&
63
+ operation(context, node.callee, "effect/Context", "Service")) {
64
+ return Option.fromNullishOr(node.typeArguments).pipe(Option.flatMap((arguments_) => Array.get(arguments_.params, 1)), Option.filter((type) => type.type === "TSTypeLiteral"), Option.map((type) => (type.type === "TSTypeLiteral" ? type.members : [])), Option.getOrElse(() => []));
65
+ }
66
+ return Array.flatMap(childNodes(context, node), fields);
67
+ };
68
+ return service.declaration.pipe(Option.map(fields), Option.getOrElse(() => []));
69
+ };
70
+ const typeModule = (context, type) => {
71
+ const name = type.typeName;
72
+ const root = name.type === "TSQualifiedName" ? name.left : name;
73
+ if (root.type !== "Identifier")
74
+ return Option.none();
75
+ return variableInScope(context.sourceCode.getScope(root), root.name).pipe(Option.flatMap((variable) => Array.head(variable.defs)), Option.flatMap((definition) => {
76
+ if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration")
77
+ return Option.none();
78
+ const source = definition.parent.source.value;
79
+ if (source === "effect" && definition.node.type === "ImportSpecifier") {
80
+ return propertyName(definition.node.imported).pipe(Option.map((member) => `effect/${member}`));
81
+ }
82
+ return Option.some(source);
83
+ }));
84
+ };
85
+ const dataMember = (context, fields, name) => Array.findFirst(fields, (field) => field.type === "TSPropertySignature" && Option.contains(propertyName(field.key), name)).pipe(Option.flatMap((field) => field.type === "TSPropertySignature"
86
+ ? Option.fromNullishOr(field.typeAnnotation?.typeAnnotation)
87
+ : Option.none()), Option.exists((type) => Array.contains(["TSStringKeyword", "TSNumberKeyword", "TSBooleanKeyword", "TSLiteralType"], type.type) ||
88
+ (type.type === "TSTypeReference" &&
89
+ typeModule(context, type).pipe(Option.exists((module) => Array.contains(["effect/Option", "effect/Stream", "effect/Schema"], module))))));
90
+ const streamValue = (context, node) => valueOf(context, node).pipe(Option.exists((value) => {
91
+ if (value.type === "CallExpression") {
92
+ if (value.callee.type === "MemberExpression" &&
93
+ Option.contains(propertyName(value.callee.property), "pipe")) {
94
+ return streamValue(context, value.callee.object);
95
+ }
96
+ return (value.callee.type === "MemberExpression" &&
97
+ value.callee.object.type === "Identifier" &&
98
+ operation(context, value.callee.object, "effect", "Stream"));
99
+ }
100
+ return (value.type === "MemberExpression" && operation(context, value.object, "effect", "Stream"));
101
+ }));
102
+ const named = (context, node, name, method) => node.type === "CallExpression" &&
103
+ operation(context, node.callee, "effect/Effect", method) &&
104
+ Array.head(node.arguments).pipe(Option.exists((argument) => argument.type === "Literal" && argument.value === name));
105
+ // A nested unrelated span is insufficient: instrumentation must wrap the value
106
+ // or the operation's returned Effect, including every explicit return branch.
107
+ const instrumented = (context, node, name, visited = []) => {
108
+ if (Array.some(visited, (previous) => previous === node))
109
+ return false;
110
+ const next = [...visited, node];
111
+ if (node.type === "Identifier") {
112
+ return valueOf(context, node).pipe(Option.exists((value) => instrumented(context, value, name, next)));
113
+ }
114
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
115
+ if (node.body === null)
116
+ return false;
117
+ if (node.body.type !== "BlockStatement")
118
+ return instrumented(context, node.body, name, next);
119
+ const returned = (statement) => {
120
+ if (statement.type === "ReturnStatement")
121
+ return Option.toArray(Option.fromNullishOr(statement.argument));
122
+ if (statement.type === "ArrowFunctionExpression" ||
123
+ statement.type === "FunctionExpression" ||
124
+ statement.type === "FunctionDeclaration")
125
+ return [];
126
+ return Array.flatMap(childNodes(context, statement), returned);
127
+ };
128
+ const values = returned(node.body);
129
+ return (values.length > 0 && Array.every(values, (value) => instrumented(context, value, name, next)));
130
+ }
131
+ if (node.type !== "CallExpression")
132
+ return false;
133
+ if (node.callee.type === "CallExpression" &&
134
+ (named(context, node.callee, name, "fn") || named(context, node.callee, name, "withSpan")))
135
+ return true;
136
+ return (((node.callee.type === "MemberExpression" &&
137
+ Option.contains(propertyName(node.callee.property), "pipe")) ||
138
+ operation(context, node.callee, "effect/Function", "pipe")) &&
139
+ Array.some(node.arguments, (argument) => named(context, argument, name, "withSpan")));
140
+ };
141
+ export const requireServiceOperationInstrumentation = defineRule({
142
+ meta: {
143
+ type: "problem",
144
+ schema: [
145
+ {
146
+ type: "object",
147
+ properties: {
148
+ serviceNames: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
149
+ operationAdapters: {
150
+ type: "array",
151
+ items: {
152
+ type: "object",
153
+ properties: {
154
+ module: { type: "string", minLength: 1 },
155
+ file: { type: "string", minLength: 1 },
156
+ exports: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } },
157
+ operation: { type: "string", minLength: 1 },
158
+ },
159
+ required: ["module", "file", "exports", "operation"],
160
+ additionalProperties: false,
161
+ },
162
+ },
163
+ },
164
+ additionalProperties: false,
165
+ },
166
+ ],
167
+ docs: {
168
+ description: "Require canonical operation spans on native services, including imported aliases and mixed contracts.",
169
+ },
170
+ messages: {
171
+ missingInstrumentation: "Instrument {{operation}} with Effect.fn or Effect.withSpan using that exact name.",
172
+ unsupportedOperation: "Declare service members as explicit named properties so their operation spans can be checked.",
173
+ },
174
+ },
175
+ create(context) {
176
+ // Oxlint owns this synchronous options boundary. Invalid options stop linting.
177
+ const options = Schema.decodeUnknownSync(Options)(Option.getOrElse(Array.head(context.options), () => ({})));
178
+ return {
179
+ CallExpression(node) {
180
+ const mock = operation(context, node.callee, "effect/Layer", "mock");
181
+ const constructor = mock
182
+ ? Array.head(node.arguments)
183
+ : node.callee.type === "MemberExpression" &&
184
+ Option.contains(propertyName(node.callee.property), "of")
185
+ ? Option.some(node.callee.object)
186
+ : Option.none();
187
+ if (Option.isNone(constructor))
188
+ return;
189
+ const target = constructor.value;
190
+ const selected = options.serviceNames;
191
+ const explicit = target.type === "Identifier" && selected?.includes(target.name);
192
+ const service = explicit
193
+ ? Option.some({
194
+ name: target.type === "Identifier" ? target.name : "",
195
+ declaration: Option.none(),
196
+ })
197
+ : selected
198
+ ? Option.none()
199
+ : serviceOf(context, target);
200
+ Option.match(service, {
201
+ onNone: () => { },
202
+ onSome: (owner) => {
203
+ const fields = contractFields(context, owner);
204
+ const argument = Array.get(node.arguments, mock ? 1 : 0).pipe(Option.flatMap((value) => valueOf(context, value)));
205
+ if (Option.isNone(argument) || argument.value.type !== "ObjectExpression") {
206
+ context.report({ node, messageId: "unsupportedOperation" });
207
+ return;
208
+ }
209
+ Array.forEach(argument.value.properties, (property) => {
210
+ if (property.type !== "Property") {
211
+ context.report({ node: property, messageId: "unsupportedOperation" });
212
+ return;
213
+ }
214
+ const key = property.computed && property.key.type !== "Literal"
215
+ ? Option.none()
216
+ : propertyName(property.key);
217
+ Option.match(key, {
218
+ onNone: () => context.report({ node: property, messageId: "unsupportedOperation" }),
219
+ onSome: (member) => {
220
+ if (dataMember(context, fields, member) || streamValue(context, property.value))
221
+ return;
222
+ const name = `${owner.name}.${member}`;
223
+ if (!instrumented(context, property.value, name) &&
224
+ !adapted(context, property.value, name, options.operationAdapters ?? []))
225
+ context.report({
226
+ node: property,
227
+ messageId: "missingInstrumentation",
228
+ data: { operation: name },
229
+ });
230
+ },
231
+ });
232
+ });
233
+ },
234
+ });
235
+ },
236
+ };
237
+ },
238
+ });
@@ -2240,12 +2240,12 @@ const SchemaOwner = Schema.Struct({
2240
2240
  });
2241
2241
  const SharedSchemaOptions = Schema.Struct({
2242
2242
  owners: nonEmpty(SchemaOwner),
2243
- fields: Schema.Array(Schema.Struct({
2243
+ fields: Schema.optionalKey(Schema.Array(Schema.Struct({
2244
2244
  file: PolicyFile,
2245
2245
  declaration: PolicyName,
2246
2246
  field: PolicyName,
2247
2247
  schema: PolicyName,
2248
- })),
2248
+ }))),
2249
2249
  });
2250
2250
  const ConstantMapOptions = Schema.Struct({
2251
2251
  maps: nonEmpty(Schema.Struct({ file: PolicyFile, name: PolicyName, constraint: PolicyConstraint })),
@@ -2377,10 +2377,13 @@ const sharedSchemaOwner = defineRule({
2377
2377
  meta: {
2378
2378
  type: "problem",
2379
2379
  schema: [
2380
- jsonObject({
2381
- owners: jsonList({ file: jsonFile, module: jsonText, names: jsonNames }, 1),
2382
- fields: jsonList({ file: jsonFile, declaration: jsonText, field: jsonText, schema: jsonText }, 0),
2383
- }),
2380
+ {
2381
+ ...jsonObject({
2382
+ owners: jsonList({ file: jsonFile, module: jsonText, names: jsonNames }, 1),
2383
+ fields: jsonList({ file: jsonFile, declaration: jsonText, field: jsonText, schema: jsonText }, 0),
2384
+ }),
2385
+ required: ["owners"],
2386
+ },
2384
2387
  ],
2385
2388
  messages: {
2386
2389
  options: "Configure the shared schema owners and the fields that must reuse them.",
@@ -2391,7 +2394,10 @@ const sharedSchemaOwner = defineRule({
2391
2394
  create(context) {
2392
2395
  const options = ruleOptions(SharedSchemaOptions)(context);
2393
2396
  const owners = Option.match(options, { onNone: () => [], onSome: (value) => value.owners });
2394
- const fields = Option.match(options, { onNone: () => [], onSome: (value) => value.fields });
2397
+ const fields = Option.match(options, {
2398
+ onNone: () => [],
2399
+ onSome: (value) => value.fields ?? [],
2400
+ });
2395
2401
  // A local `const` may alias the owner's export of the same name.
2396
2402
  const importedAlias = (node, name) => scopedVariable(context.sourceCode.getScope(node), name).pipe(Option.flatMap(constantDeclaration), Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.exists((value) => Array.some(owners, (owner) => Array.contains(owner.names, name) && ownerExport(context, value, name, owner))));
2397
2403
  const checkName = (node, name, alias) => {
@@ -0,0 +1,5 @@
1
+ export declare const typographyRules: {
2
+ "semantic-typography": import("@oxlint/plugins").Rule;
3
+ "gap-layout": import("@oxlint/plugins").Rule;
4
+ "safe-type-line-height": import("@oxlint/plugins").Rule;
5
+ };
@@ -0,0 +1,98 @@
1
+ import { Array, Option, Predicate } from "effect";
2
+ import { report, rule } from "../conventions/shared.js";
3
+ const stringValue = (node) => node.type === "Literal" && Predicate.isString(node.value)
4
+ ? Option.some(node.value)
5
+ : node.type === "TemplateElement"
6
+ ? Option.some(node.value.cooked ?? node.value.raw)
7
+ : Option.none();
8
+ const classContext = (node) => {
9
+ const parent = node.parent;
10
+ if (parent === null)
11
+ return false;
12
+ if (parent.type === "JSXAttribute" &&
13
+ parent.name.type === "JSXIdentifier" &&
14
+ Array.contains(["class", "className"], parent.name.name))
15
+ return true;
16
+ if (parent.type === "CallExpression" &&
17
+ parent.callee.type === "Identifier" &&
18
+ Array.contains(["cn", "clsx", "cva", "twMerge"], parent.callee.name))
19
+ return true;
20
+ return classContext(parent);
21
+ };
22
+ // Variant colons inside arbitrary selectors/values do not delimit the utility.
23
+ const utility = (text) => {
24
+ const state = Array.reduce(Array.fromIterable(text), { square: 0, paren: 0, last: -1, index: 0 }, (previous, character) => ({
25
+ square: character === "["
26
+ ? previous.square + 1
27
+ : character === "]"
28
+ ? Math.max(0, previous.square - 1)
29
+ : previous.square,
30
+ paren: character === "("
31
+ ? previous.paren + 1
32
+ : character === ")"
33
+ ? Math.max(0, previous.paren - 1)
34
+ : previous.paren,
35
+ last: character === ":" && previous.square === 0 && previous.paren === 0
36
+ ? previous.index
37
+ : previous.last,
38
+ index: previous.index + 1,
39
+ }));
40
+ return text
41
+ .slice(state.last + 1)
42
+ .replace(/^!|!$/gu, "")
43
+ .replace(/\/.*$/u, "");
44
+ };
45
+ const typography = [
46
+ "font-bold",
47
+ "font-medium",
48
+ "font-mono",
49
+ "font-normal",
50
+ "font-sans",
51
+ "font-semibold",
52
+ "leading-none",
53
+ "leading-normal",
54
+ "leading-relaxed",
55
+ "leading-snug",
56
+ "leading-tight",
57
+ "text-2xl",
58
+ "text-3xl",
59
+ "text-4xl",
60
+ "text-base",
61
+ "text-lg",
62
+ "text-md",
63
+ "text-sm",
64
+ "text-xl",
65
+ "text-xs",
66
+ "tracking-display",
67
+ "tracking-loose",
68
+ "tracking-tight",
69
+ ];
70
+ export const typographyRules = {
71
+ "semantic-typography": rule("Use shared semantic typography primitives instead of local text-size, line-height, tracking and font utility piles.", (context) => {
72
+ const check = (node) => {
73
+ if (classContext(node) &&
74
+ stringValue(node).pipe(Option.exists((value) => Array.some(value.split(/\s+/u), (word) => Array.contains(typography, utility(word))))))
75
+ report(context, node);
76
+ };
77
+ return { Literal: check, TemplateElement: check };
78
+ }),
79
+ "gap-layout": rule("Use flex/grid gap rather than sibling-margin space-x/space-y utilities.", (context) => {
80
+ const check = (node) => {
81
+ if (classContext(node) &&
82
+ stringValue(node).pipe(Option.exists((value) => Array.some(value.split(/\s+/u), (word) => /^space-[xy]-/u.test(utility(word))))))
83
+ report(context, node);
84
+ };
85
+ return { Literal: check, TemplateElement: check };
86
+ }),
87
+ "safe-type-line-height": rule("Use semantic line-height tokens; reject calculated divisions and ratios below one.", (context) => {
88
+ const check = (node) => {
89
+ if (stringValue(node).pipe(Option.exists((value) => Array.some(Array.fromIterable(value.matchAll(/--text-[a-z0-9-]+--line-height\s*:\s*(?<value>[^;"'`\n}]+)/giu)), (match) => {
90
+ const text = match.groups?.value?.trim() ?? "";
91
+ const number = Number(text);
92
+ return (/^calc\([^)]*\/[^)]*\)$/u.test(text) || (Number.isFinite(number) && number < 1));
93
+ }))))
94
+ report(context, node);
95
+ };
96
+ return { Literal: check, TemplateElement: check };
97
+ }),
98
+ };
package/dist/web.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@oxlint/plugins").Plugin;
2
+ export default _default;
package/dist/web.js ADDED
@@ -0,0 +1,120 @@
1
+ import { eslintCompatPlugin } from "@oxlint/plugins";
2
+ import { Array, Option, Predicate } from "effect";
3
+ import { propertyName } from "./syntax.js";
4
+ import { report, rule } from "./conventions/shared.js";
5
+ import { typographyRules } from "./web/typography.js";
6
+ const nameOf = (node) => node.type === "JSXIdentifier" ? Option.some(node.name) : propertyName(node);
7
+ const routes = (filename) => /(?:^|\/)src\/routes\/.+\.[jt]sx?$/u.test(filename);
8
+ const mdxKeys = [
9
+ "a",
10
+ "blockquote",
11
+ "code",
12
+ "h1",
13
+ "h2",
14
+ "h3",
15
+ "h4",
16
+ "h5",
17
+ "h6",
18
+ "hr",
19
+ "img",
20
+ "li",
21
+ "ol",
22
+ "p",
23
+ "pre",
24
+ "table",
25
+ "tbody",
26
+ "td",
27
+ "th",
28
+ "thead",
29
+ "tr",
30
+ "ul",
31
+ ];
32
+ export default eslintCompatPlugin({
33
+ meta: { name: "web" },
34
+ rules: {
35
+ ...typographyRules,
36
+ "no-context-nullish-default": rule("Use canonical Schema-owned metadata and preserve absent values rather than inventing defaults.", (context) => ({
37
+ LogicalExpression(node) {
38
+ if (node.operator === "??" &&
39
+ node.left.type === "MemberExpression" &&
40
+ Option.exists(propertyName(node.left.property), (name) => Array.contains(["description", "image", "title"], name)))
41
+ report(context, node);
42
+ },
43
+ })),
44
+ "no-page-wrapper": rule("Compose the route in its owning route component rather than delegating to a page-sized wrapper.", (context) => ({
45
+ Property(node) {
46
+ if (routes(context.filename) &&
47
+ Option.contains(propertyName(node.key), "component") &&
48
+ node.value.type === "Identifier" &&
49
+ /^[A-Z][A-Za-z]*Page$/u.test(node.value.name))
50
+ report(context, node.value);
51
+ },
52
+ })),
53
+ "no-route-local-seo": rule("Reuse the canonical checked route metadata builder.", (context) => ({
54
+ CallExpression(node) {
55
+ if (node.callee.type === "Identifier" && node.callee.name === "seo")
56
+ report(context, node);
57
+ },
58
+ })),
59
+ "no-route-local-markdown-negotiation": rule("Keep Markdown negotiation in the application's request middleware owner.", (context) => ({
60
+ ImportDeclaration(node) {
61
+ if (/[/]seo-aeo[/]markdown-negotiation$/u.test(node.source.value))
62
+ report(context, node);
63
+ },
64
+ })),
65
+ "no-unscoped-server-functions": rule("Use the application's typed runtime boundary rather than creating TanStack server-function wrappers.", (context) => ({
66
+ ImportDeclaration(node) {
67
+ if (node.source.value === "@tanstack/react-start")
68
+ Array.forEach(node.specifiers, (specifier) => {
69
+ if (specifier.type === "ImportSpecifier" &&
70
+ Option.exists(propertyName(specifier.imported), (name) => name === "createServerFn" || name === "createServerOnlyFn"))
71
+ report(context, specifier);
72
+ });
73
+ },
74
+ })),
75
+ "no-route-json-ld": rule("Let the canonical checked metadata owner encode JSON-LD scripts.", (context) => ({
76
+ Property(node) {
77
+ if (routes(context.filename) &&
78
+ Option.contains(propertyName(node.key), "type") &&
79
+ node.value.type === "Literal" &&
80
+ node.value.value === "application/ld+json")
81
+ report(context, node.value);
82
+ },
83
+ })),
84
+ "no-module-owned-public-asset-url": rule("Import module-owned image and pattern assets through the bundler's module graph.", (context) => ({
85
+ JSXAttribute(node) {
86
+ if (Option.contains(nameOf(node.name), "src") &&
87
+ node.value?.type === "Literal" &&
88
+ Predicate.isString(node.value.value) &&
89
+ /^\/(?:images|patterns)\//u.test(node.value.value))
90
+ report(context, node.value);
91
+ },
92
+ })),
93
+ "no-http-globals-in-seo": rule("Keep Web HTTP adaptation outside SEO and AEO domain modules.", (context) => ({
94
+ Identifier(node) {
95
+ if (/(?:\/(?:seo|seo-aeo)\/|\/packages\/aeo\/)/u.test(context.filename) &&
96
+ Array.contains(["Request", "Response", "Headers"], node.name))
97
+ report(context, node);
98
+ },
99
+ })),
100
+ "no-local-mdx-registry": rule("Reuse a shared MDX component registry rather than rebuilding it in a route or component.", (context) => ({
101
+ VariableDeclarator(node) {
102
+ if (node.id.type === "Identifier" &&
103
+ (node.id.name === "mdxComponents" ||
104
+ (node.id.name === "components" &&
105
+ node.init?.type === "ObjectExpression" &&
106
+ Array.some(node.init.properties, (property) => property.type === "Property" &&
107
+ Option.exists(propertyName(property.key), (name) => Array.contains(mdxKeys, name))))))
108
+ report(context, node.id);
109
+ },
110
+ JSXAttribute(node) {
111
+ if (Option.contains(nameOf(node.name), "components") &&
112
+ node.parent.type === "JSXOpeningElement" &&
113
+ Option.contains(nameOf(node.parent.name), "MDX") &&
114
+ node.value?.type === "JSXExpressionContainer" &&
115
+ node.value.expression.type === "ObjectExpression")
116
+ report(context, node);
117
+ },
118
+ })),
119
+ },
120
+ });