@commonplaces/oxlint-plugin 0.1.1 → 0.3.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.
@@ -0,0 +1,219 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { Array, Option, Predicate } from "effect";
3
+ import { operation, valueOf } from "./native-reference.js";
4
+ import { propertyName } from "./syntax.js";
5
+ import { channelMember, memberName, nonPipe } from "./subprocess-capture-dataflow.js";
6
+ const functionNode = (node) => node.type === "ArrowFunctionExpression" ||
7
+ node.type === "FunctionExpression" ||
8
+ node.type === "FunctionDeclaration";
9
+ const terminalNames = [
10
+ "run",
11
+ "runCollect",
12
+ "runDrain",
13
+ "runFold",
14
+ "runFoldEffect",
15
+ "runForEach",
16
+ "mkString",
17
+ ];
18
+ const streamTerminal = (context, node) => {
19
+ if (node.type === "CallExpression")
20
+ return streamTerminal(context, node.callee);
21
+ return Array.some(terminalNames, (name) => operation(context, node, "effect/Stream", name));
22
+ };
23
+ const effectAll = (context, node) => node.type === "CallExpression" && operation(context, node.callee, "effect/Effect", "all");
24
+ const forked = (context, node) => node.type === "CallExpression"
25
+ ? forked(context, node.callee)
26
+ : Array.some(["forkChild", "forkScoped", "forkDaemon", "forkDetach"], (name) => operation(context, node, "effect/Effect", name));
27
+ const effectTransforms = [
28
+ "map",
29
+ "mapError",
30
+ "as",
31
+ "asVoid",
32
+ "timeout",
33
+ "timeoutOrElse",
34
+ "withSpan",
35
+ ];
36
+ const nativeTransform = (context, node, module, names) => node.type === "CallExpression"
37
+ ? nativeTransform(context, node.callee, module, names)
38
+ : Array.some(names, (name) => operation(context, node, module, name));
39
+ const effectTransform = (context, node) => nativeTransform(context, node, "effect/Effect", effectTransforms);
40
+ const streamPipeline = (context, transforms) => Array.findFirstIndex(transforms, (node) => streamTerminal(context, node)).pipe(Option.exists((terminal) => Array.every(Array.take(transforms, terminal), (node) => nativeTransform(context, node, "effect/Stream", [
41
+ "decodeText",
42
+ "map",
43
+ "mapEffect",
44
+ "filter",
45
+ "take",
46
+ "takeWhile",
47
+ "filterMap",
48
+ ])) &&
49
+ Array.every(Array.drop(transforms, terminal + 1), (node) => effectTransform(context, node))));
50
+ const captures = (context, node, visited = []) => {
51
+ if (functionNode(node) || Array.some(visited, (seen) => seen === node))
52
+ return [];
53
+ const next = [...visited, node];
54
+ const direct = channelMember(context, node).pipe(Option.filter((capture) => !nonPipe(context, capture.handle, capture.channel)));
55
+ if (Option.isSome(direct))
56
+ return direct.value.channel === "exitCode" ? [direct.value] : [];
57
+ if (node.type === "Identifier")
58
+ return valueOf(context, node).pipe(Option.filter((value) => value !== node), Option.match({ onNone: () => [], onSome: (value) => captures(context, value, next) }));
59
+ if (node.type !== "CallExpression")
60
+ return [];
61
+ // Forking already changes execution ownership; this rule does not infer fibre lifetimes.
62
+ if (forked(context, node.callee) ||
63
+ Array.some(node.arguments, (argument) => forked(context, argument)))
64
+ return [];
65
+ if (node.callee.type === "MemberExpression" && Option.contains(memberName(node.callee), "pipe")) {
66
+ const stream = channelMember(context, node.callee.object).pipe(Option.filter((capture) => !nonPipe(context, capture.handle, capture.channel)));
67
+ if (Option.isSome(stream) && stream.value.channel !== "exitCode")
68
+ return streamPipeline(context, node.arguments) ? [stream.value] : [];
69
+ return Array.every(node.arguments, (argument) => effectTransform(context, argument))
70
+ ? captures(context, node.callee.object, next)
71
+ : [];
72
+ }
73
+ if (streamTerminal(context, node.callee))
74
+ return Array.head(node.arguments).pipe(Option.flatMap((argument) => channelMember(context, argument).pipe(Option.filter((capture) => capture.channel !== "exitCode" && !nonPipe(context, capture.handle, capture.channel)))), Option.toArray);
75
+ if (operation(context, node.callee, "effect/Function", "pipe") ||
76
+ operation(context, node.callee, "effect", "pipe")) {
77
+ return Array.head(node.arguments).pipe(Option.flatMap((argument) => channelMember(context, argument)), Option.filter((capture) => !nonPipe(context, capture.handle, capture.channel) &&
78
+ (capture.channel === "exitCode"
79
+ ? Array.every(Array.drop(node.arguments, 1), (argument) => effectTransform(context, argument))
80
+ : streamPipeline(context, Array.drop(node.arguments, 1)))), Option.match({ onNone: () => [], onSome: (capture) => [capture] }));
81
+ }
82
+ // Native Effect wrappers keep their input lazy; arbitrary callbacks are not followed.
83
+ return effectTransform(context, node.callee)
84
+ ? Array.head(node.arguments).pipe(Option.match({
85
+ onNone: () => [],
86
+ onSome: (self) => captures(context, self, next),
87
+ }))
88
+ : [];
89
+ };
90
+ const jobs = (context, node) => valueOf(context, node).pipe(Option.flatMap((value) => {
91
+ if (value.type === "ArrayExpression")
92
+ return Array.every(value.elements, (element) => element !== null && element.type !== "SpreadElement")
93
+ ? Option.some(Array.filter(value.elements, (element) => element !== null))
94
+ : Option.none();
95
+ if (value.type === "ObjectExpression") {
96
+ const names = Array.flatMap(value.properties, (property) => property.type === "Property" ? Option.toArray(propertyName(property.key)) : []);
97
+ if (names.length !== value.properties.length ||
98
+ Array.dedupe(names).length !== names.length ||
99
+ Array.some(names, (name) => /^\d+$/.test(name) || name === "__proto__"))
100
+ return Option.none();
101
+ return Array.every(value.properties, (property) => property.type === "Property" &&
102
+ property.kind === "init" &&
103
+ !property.computed &&
104
+ !Option.contains(propertyName(property.key), "__proto__"))
105
+ ? Option.some(Array.flatMap(value.properties, (property) => property.type === "Property" ? [property.value] : []))
106
+ : Option.none();
107
+ }
108
+ return Option.none();
109
+ }));
110
+ // Unknown concurrency is deliberately unclassified. Only a missing or statically
111
+ // insufficient bound is rejected; this is not a proof of general process safety.
112
+ const concurrency = (context, call) => Array.get(call.arguments, 1).pipe(Option.match({
113
+ onNone: () => Option.some(1),
114
+ onSome: (options) => valueOf(context, options).pipe(Option.filter((value) => value.type === "ObjectExpression"), Option.filter((value) => Array.every(value.properties, (property) => property.type === "Property" &&
115
+ property.kind === "init" &&
116
+ !property.computed &&
117
+ !Option.contains(propertyName(property.key), "__proto__"))), Option.flatMap((value) => Array.findLast(value.properties, (property) => property.type === "Property" &&
118
+ Option.contains(propertyName(property.key), "concurrency")).pipe(Option.match({
119
+ onNone: () => Option.some(1),
120
+ onSome: (property) => property.type === "Property"
121
+ ? valueOf(context, property.value).pipe(Option.filter((setting) => setting.type === "Literal"), Option.flatMap((setting) => setting.value === "unbounded"
122
+ ? Option.some(Number.POSITIVE_INFINITY)
123
+ : Predicate.isNumber(setting.value)
124
+ ? Option.some(setting.value)
125
+ : Option.none()))
126
+ : Option.none(),
127
+ })))),
128
+ }));
129
+ const conflicts = (left, right) => left.handle === right.handle && left.channel !== right.channel;
130
+ const waitingCapture = (context, tasks, bound) => {
131
+ const indexed = Array.flatMap(tasks, (task, index) => Array.map(captures(context, task), (capture) => ({ capture, index })));
132
+ return Array.some(indexed, ({ capture }) => {
133
+ const related = Array.filter(indexed, (entry) => entry.capture.handle === capture.handle);
134
+ return (Array.some(related, (entry) => entry.index < bound) &&
135
+ Array.some(related, (entry) => conflicts(capture, entry.capture)) &&
136
+ Array.some(related, (entry) => entry.capture.channel !== "exitCode" && entry.index >= bound));
137
+ });
138
+ };
139
+ const yielded = (node) => {
140
+ if (node.type === "ExpressionStatement" || node.type === "ReturnStatement") {
141
+ const expression = node.type === "ExpressionStatement" ? node.expression : node.argument;
142
+ return expression?.type === "YieldExpression" && expression.delegate ? [expression] : [];
143
+ }
144
+ return node.type === "VariableDeclaration"
145
+ ? Array.flatMap(node.declarations, (declaration) => declaration.init?.type === "YieldExpression" && declaration.init.delegate
146
+ ? [declaration.init]
147
+ : [])
148
+ : [];
149
+ };
150
+ const event = (context, node) => {
151
+ if (node.argument?.type === "CallExpression" && effectAll(context, node.argument)) {
152
+ const call = node.argument;
153
+ const entries = Array.head(call.arguments).pipe(Option.flatMap((input) => jobs(context, input)));
154
+ return Option.match(entries, {
155
+ onNone: () => ({ node, captures: [], completed: [] }),
156
+ onSome: (tasks) => {
157
+ const consumed = Array.flatMap(tasks, (task) => captures(context, task));
158
+ return {
159
+ node,
160
+ captures: consumed,
161
+ // This group has returned before the next yield. Finished pipe drains
162
+ // may safely precede a later exit wait, regardless of their old bound.
163
+ completed: Array.flatMap(consumed, (capture) => Array.every(["stdout", "stderr"], (channel) => nonPipe(context, capture.handle, channel) ||
164
+ Array.some(consumed, (other) => other.handle === capture.handle && other.channel === channel))
165
+ ? [capture.handle]
166
+ : []),
167
+ };
168
+ },
169
+ });
170
+ }
171
+ return { node, captures: node.argument ? captures(context, node.argument) : [], completed: [] };
172
+ };
173
+ const nativeGenerator = (context, block) => {
174
+ const fn = block.parent;
175
+ if ((fn?.type !== "FunctionExpression" && fn?.type !== "FunctionDeclaration") || !fn.generator)
176
+ return false;
177
+ const call = fn.parent;
178
+ if (call?.type !== "CallExpression")
179
+ return false;
180
+ return (operation(context, call.callee, "effect/Effect", "gen") ||
181
+ operation(context, call.callee, "effect/Effect", "fnUntraced") ||
182
+ (call.callee.type === "CallExpression" &&
183
+ operation(context, call.callee.callee, "effect/Effect", "fn")));
184
+ };
185
+ export const subprocessPipeCapture = defineRule({
186
+ meta: {
187
+ type: "problem",
188
+ schema: [],
189
+ messages: {
190
+ capture: "Drain a subprocess's captured pipes while waiting for exit. Use explicit Effect.all concurrency that starts the required pipe drains together; sequential collection can block on a full OS pipe.",
191
+ },
192
+ },
193
+ create(context) {
194
+ return {
195
+ CallExpression(node) {
196
+ if (!effectAll(context, node))
197
+ return;
198
+ const entries = Array.head(node.arguments).pipe(Option.flatMap((input) => jobs(context, input)));
199
+ if (Option.exists(entries, (tasks) => Option.exists(concurrency(context, node), (bound) => waitingCapture(context, tasks, bound))))
200
+ context.report({ node, messageId: "capture" });
201
+ },
202
+ "BlockStatement:exit"(node) {
203
+ if (!nativeGenerator(context, node))
204
+ return;
205
+ const events = Array.map(Array.flatMap(node.body, yielded), (yielded) => event(context, yielded));
206
+ Array.forEach(events, (current, index) => {
207
+ const previous = Array.take(events, index);
208
+ const closed = Array.flatMap(previous, (entry) => entry.completed);
209
+ if (Array.some(previous, (earlier) => Array.some(earlier.captures, (left) => !Array.some(closed, (handle) => handle === left.handle) &&
210
+ Array.some(current.captures, (right) => conflicts(left, right) &&
211
+ (left.channel === "exitCode" ||
212
+ right.channel !== "exitCode" ||
213
+ !nonPipe(context, left.handle, left.channel === "stdout" ? "stderr" : "stdout"))))))
214
+ context.report({ node: current.node, messageId: "capture" });
215
+ });
216
+ },
217
+ };
218
+ },
219
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonplaces/oxlint-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Shared Oxlint rules for strict Effect TypeScript, Alchemy and repository code.",
5
5
  "license": "MIT",
6
6
  "author": "Cooper Corbett",
@@ -71,7 +71,7 @@
71
71
  "vitest": "5.0.1"
72
72
  },
73
73
  "peerDependencies": {
74
- "oxlint": "1.80.0 || 1.85.0"
74
+ "oxlint": "1.80.0 || 1.85.0 || 1.86.0"
75
75
  },
76
76
  "engines": {
77
77
  "node": ">=22.14.0"