@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/README.md +81 -16
- package/dist/conventions/bounded-build-memo.d.ts +1 -0
- package/dist/conventions/bounded-build-memo.js +147 -0
- package/dist/conventions/codecs.d.ts +6 -0
- package/dist/conventions/codecs.js +31 -0
- package/dist/conventions/contracts.d.ts +11 -0
- package/dist/conventions/contracts.js +105 -0
- package/dist/conventions/lifetimes.d.ts +7 -0
- package/dist/conventions/lifetimes.js +179 -0
- package/dist/conventions/ownership.d.ts +2 -0
- package/dist/conventions/ownership.js +86 -0
- package/dist/conventions/shared.d.ts +11 -0
- package/dist/conventions/shared.js +54 -0
- package/dist/conventions/syntax.d.ts +14 -0
- package/dist/conventions/syntax.js +172 -0
- package/dist/conventions.d.ts +2 -0
- package/dist/conventions.js +19 -0
- package/dist/effect.js +3 -86
- package/dist/index.d.ts +123 -0
- package/dist/index.js +44 -0
- package/dist/native-reference.d.ts +6 -0
- package/dist/native-reference.js +9 -7
- package/dist/package.js +1 -1
- package/dist/service-instrumentation.d.ts +1 -0
- package/dist/service-instrumentation.js +238 -0
- package/dist/strict-effect.js +13 -7
- package/dist/web/typography.d.ts +5 -0
- package/dist/web/typography.js +98 -0
- package/dist/web.d.ts +2 -0
- package/dist/web.js +120 -0
- package/package.json +9 -1
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 {};
|
package/dist/native-reference.js
CHANGED
|
@@ -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" ||
|
|
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)\/
|
|
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
|
+
});
|
package/dist/strict-effect.js
CHANGED
|
@@ -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
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
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, {
|
|
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,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
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
|
+
});
|