@commonplaces/oxlint-plugin 0.3.1 → 0.5.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.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare const effect: {
30
30
  "effect/no-instanceof": "error";
31
31
  "effect/no-manual-tag": "error";
32
32
  "effect/no-raw-fetch": "error";
33
+ "effect/require-service-operation-instrumentation": "error";
33
34
  "strict-effect/static-layers": "error";
34
35
  "strict-effect/subprocess-pipe-capture": "error";
35
36
  "strict-effect/no-runtime-type-assertions": "error";
@@ -47,6 +48,41 @@ export declare const effect: {
47
48
  "anti-slop-effect/no-service-constructor-imports": "error";
48
49
  };
49
50
  };
51
+ /** Optional strict coding conventions; boundary permissions remain project facts. */
52
+ export declare const conventions: {
53
+ jsPlugins: string[];
54
+ rules: Record<`effect-conventions/${string}`, "error">;
55
+ };
56
+ /** Optional React/TanStack, MDX, semantic typography and route conventions. */
57
+ export declare const web: {
58
+ plugins: ("jsx-a11y" | "react" | "react-perf")[];
59
+ jsPlugins: string[];
60
+ rules: Record<`web/${string}`, "error">;
61
+ };
62
+ /** Native type-aware checks. Run Oxlint with --type-aware. */
63
+ export declare const typescript: {
64
+ plugins: ("import" | "typescript" | "unicorn" | "vitest")[];
65
+ categories: {
66
+ correctness: "error";
67
+ };
68
+ rules: {
69
+ "no-ternary": "error";
70
+ "typescript/consistent-type-assertions": ["error", {
71
+ assertionStyle: "never";
72
+ }];
73
+ "typescript/no-explicit-any": "error";
74
+ "typescript/no-non-null-assertion": "error";
75
+ "typescript/await-thenable": "error";
76
+ "typescript/no-floating-promises": "error";
77
+ "typescript/no-misused-promises": "error";
78
+ "typescript/no-unsafe-argument": "error";
79
+ "typescript/no-unsafe-assignment": "error";
80
+ "typescript/no-unsafe-call": "error";
81
+ "typescript/no-unsafe-member-access": "error";
82
+ "typescript/no-unsafe-return": "error";
83
+ "typescript/no-unsafe-type-assertion": "error";
84
+ };
85
+ };
50
86
  /** Native Alchemy configuration and secret-source checks. */
51
87
  export declare const alchemy: {
52
88
  jsPlugins: string[];
@@ -57,3 +93,91 @@ export declare const alchemy: {
57
93
  "strict-effect/native-cloudflare-cache": "error";
58
94
  };
59
95
  };
96
+ /** Complete strict Effect application profile; exact project owners stay local. */
97
+ export declare const strict: {
98
+ extends: ({
99
+ jsPlugins: string[];
100
+ rules: {
101
+ "anti-slop/no-chained-type-assertions": "error";
102
+ "anti-slop/no-conditional-empty-object-spread": "error";
103
+ "anti-slop/no-known-value-widening": "error";
104
+ "anti-slop/no-module-mocking": "error";
105
+ "anti-slop/no-object-parameters": "error";
106
+ "anti-slop/no-reflect-apply": "error";
107
+ "anti-slop/no-reflect-get": "error";
108
+ "anti-slop/no-runtime-typeof": "error";
109
+ "anti-slop/no-shape-in-symbol-names": "error";
110
+ "anti-slop/no-unknown-parameters": "error";
111
+ "anti-slop/no-unknown-returns": "error";
112
+ "anti-slop/no-unknown-type-aliases": "error";
113
+ "anti-slop/no-unsafe-dictionary-type": "error";
114
+ "anti-slop/no-widen-then-assert": "error";
115
+ "anti-slop/require-safety-comment-for-type-assertion": "error";
116
+ "package/no-cross-package-source-imports": "error";
117
+ };
118
+ } | {
119
+ jsPlugins: string[];
120
+ rules: {
121
+ "effect/no-ambient-process": "error";
122
+ "effect/no-async-await-promise": "error";
123
+ "effect/no-console": "error";
124
+ "effect/no-instanceof": "error";
125
+ "effect/no-manual-tag": "error";
126
+ "effect/no-raw-fetch": "error";
127
+ "effect/require-service-operation-instrumentation": "error";
128
+ "strict-effect/static-layers": "error";
129
+ "strict-effect/subprocess-pipe-capture": "error";
130
+ "strict-effect/no-runtime-type-assertions": "error";
131
+ "strict-effect/no-unchecked-index": "error";
132
+ "strict-effect/no-native-at": "error";
133
+ "strict-effect/tagged-error-name": "error";
134
+ "strict-effect/error-constructor-new": "error";
135
+ "strict-effect/no-promise-workflow": "error";
136
+ "strict-effect/no-unsafe-option-unwrap": "error";
137
+ "strict-effect/no-unchecked-json": "error";
138
+ "strict-effect/no-native-work": "error";
139
+ "strict-effect/no-imperative-collections": "error";
140
+ "strict-effect/no-native-client-replica": "error";
141
+ "bun/no-ambient-host-api": "error";
142
+ "anti-slop-effect/no-service-constructor-imports": "error";
143
+ };
144
+ } | {
145
+ jsPlugins: string[];
146
+ rules: Record<`effect-conventions/${string}`, "error">;
147
+ } | {
148
+ plugins: ("jsx-a11y" | "react" | "react-perf")[];
149
+ jsPlugins: string[];
150
+ rules: Record<`web/${string}`, "error">;
151
+ } | {
152
+ plugins: ("import" | "typescript" | "unicorn" | "vitest")[];
153
+ categories: {
154
+ correctness: "error";
155
+ };
156
+ rules: {
157
+ "no-ternary": "error";
158
+ "typescript/consistent-type-assertions": ["error", {
159
+ assertionStyle: "never";
160
+ }];
161
+ "typescript/no-explicit-any": "error";
162
+ "typescript/no-non-null-assertion": "error";
163
+ "typescript/await-thenable": "error";
164
+ "typescript/no-floating-promises": "error";
165
+ "typescript/no-misused-promises": "error";
166
+ "typescript/no-unsafe-argument": "error";
167
+ "typescript/no-unsafe-assignment": "error";
168
+ "typescript/no-unsafe-call": "error";
169
+ "typescript/no-unsafe-member-access": "error";
170
+ "typescript/no-unsafe-return": "error";
171
+ "typescript/no-unsafe-type-assertion": "error";
172
+ };
173
+ } | {
174
+ jsPlugins: string[];
175
+ rules: {
176
+ "alchemy-secrets/native-sources": "error";
177
+ "alchemy-secrets/explicit-stack-sources": "error";
178
+ "alchemy-secrets/no-doppler-wrapper": "error";
179
+ "strict-effect/native-cloudflare-cache": "error";
180
+ };
181
+ })[];
182
+ };
183
+ export { default as style } from "./style.js";
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,8 @@ 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
+ };
109
+ export { default as style } from "./style.js";
@@ -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
+ });
@@ -596,6 +596,23 @@ const layerExpression = (context, expression, visited = []) => {
596
596
  if (Array.isReadonlyArrayEmpty(expression.arguments)) {
597
597
  return layerExpression(context, expression.callee.object, next);
598
598
  }
599
+ // Completed Match.value pipelines return their branch values, including
600
+ // Layers. Inline native callbacks remain allowed; an owned factory does not.
601
+ const completesMatch = Array.last(expression.arguments).pipe(Option.exists((argument) => scopedEffectReference(context, argument.type === "CallExpression" ? argument.callee : argument).pipe(Option.exists((reference) => isExport(reference, "effect/Match", "exhaustive") ||
602
+ isExport(reference, "effect/Match", "orElse")))));
603
+ const valueMatch = expression.callee.object.type === "CallExpression" &&
604
+ scopedEffectReference(context, expression.callee.object.callee).pipe(Option.exists((reference) => isExport(reference, "effect/Match", "value")));
605
+ const branchReturnsLayer = (argument) => argument.type === "CallExpression" &&
606
+ scopedEffectReference(context, argument.callee).pipe(Option.exists((reference) => reference.module === "effect/Match")) &&
607
+ Array.some((callback) => (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") &&
608
+ Option.fromNullishOr(callback.body).pipe(Option.exists((body) => body.type === "BlockStatement"
609
+ ? returnsLayer(context, body)
610
+ : layerExpression(context, body, next))))(argument.arguments);
611
+ if (valueMatch &&
612
+ completesMatch &&
613
+ Array.some(branchReturnsLayer)(expression.arguments)) {
614
+ return true;
615
+ }
599
616
  return transforms(expression.arguments);
600
617
  }
601
618
  return false;
@@ -2240,12 +2257,12 @@ const SchemaOwner = Schema.Struct({
2240
2257
  });
2241
2258
  const SharedSchemaOptions = Schema.Struct({
2242
2259
  owners: nonEmpty(SchemaOwner),
2243
- fields: Schema.Array(Schema.Struct({
2260
+ fields: Schema.optionalKey(Schema.Array(Schema.Struct({
2244
2261
  file: PolicyFile,
2245
2262
  declaration: PolicyName,
2246
2263
  field: PolicyName,
2247
2264
  schema: PolicyName,
2248
- })),
2265
+ }))),
2249
2266
  });
2250
2267
  const ConstantMapOptions = Schema.Struct({
2251
2268
  maps: nonEmpty(Schema.Struct({ file: PolicyFile, name: PolicyName, constraint: PolicyConstraint })),
@@ -2377,10 +2394,13 @@ const sharedSchemaOwner = defineRule({
2377
2394
  meta: {
2378
2395
  type: "problem",
2379
2396
  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
- }),
2397
+ {
2398
+ ...jsonObject({
2399
+ owners: jsonList({ file: jsonFile, module: jsonText, names: jsonNames }, 1),
2400
+ fields: jsonList({ file: jsonFile, declaration: jsonText, field: jsonText, schema: jsonText }, 0),
2401
+ }),
2402
+ required: ["owners"],
2403
+ },
2384
2404
  ],
2385
2405
  messages: {
2386
2406
  options: "Configure the shared schema owners and the fields that must reuse them.",
@@ -2391,7 +2411,10 @@ const sharedSchemaOwner = defineRule({
2391
2411
  create(context) {
2392
2412
  const options = ruleOptions(SharedSchemaOptions)(context);
2393
2413
  const owners = Option.match(options, { onNone: () => [], onSome: (value) => value.owners });
2394
- const fields = Option.match(options, { onNone: () => [], onSome: (value) => value.fields });
2414
+ const fields = Option.match(options, {
2415
+ onNone: () => [],
2416
+ onSome: (value) => value.fields ?? [],
2417
+ });
2395
2418
  // A local `const` may alias the owner's export of the same name.
2396
2419
  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
2420
  const checkName = (node, name, alias) => {
@@ -0,0 +1,4 @@
1
+ import type { OxlintConfig } from "oxlint";
2
+ /** Optional shared style checks. Extend before strict and the project policy. */
3
+ declare const style: OxlintConfig;
4
+ export default style;
package/dist/style.js ADDED
@@ -0,0 +1,38 @@
1
+ import { Array, Record } from "effect";
2
+ import core from "ultracite/oxlint/core";
3
+ import react from "ultracite/oxlint/react";
4
+ import vitest from "ultracite/oxlint/vitest";
5
+ /** Optional shared style checks. Extend before strict and the project policy. */
6
+ const style = {
7
+ extends: [
8
+ core,
9
+ {
10
+ ...react,
11
+ // Leave these correctness rules to the host category settings. Explicit
12
+ // upstream "off" values would beat strict's correctness category.
13
+ rules: Record.filter(react.rules ?? {}, (_, name) => name !== "jsx-a11y/no-autofocus" && name !== "react/forward-ref-uses-ref"),
14
+ },
15
+ {
16
+ ...vitest,
17
+ // Keep the host's correctness check for test titles in test files too.
18
+ overrides: Array.map(vitest.overrides ?? [], (override) => ({
19
+ ...override,
20
+ rules: Record.filter(override.rules ?? {}, (_, name) => name !== "vitest/valid-title"),
21
+ })),
22
+ },
23
+ ],
24
+ rules: {
25
+ "unicorn/no-array-for-each": "off",
26
+ "unicorn/no-array-sort": "off",
27
+ "unicorn/throw-new-error": "off",
28
+ "no-redeclare": "off",
29
+ "react/function-component-definition": "off",
30
+ "sort-keys": "off",
31
+ "func-names": "off",
32
+ "func-style": "off",
33
+ "no-use-before-define": "off",
34
+ "no-inline-comments": "off",
35
+ "typescript/consistent-return": "off",
36
+ },
37
+ };
38
+ export default style;
@@ -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
+ };