@aas-dev/stack 0.0.0-stage → 0.1.1

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/plugin/aas.mjs ADDED
@@ -0,0 +1,572 @@
1
+ const markers = [
2
+ "@ts-ignore",
3
+ "@ts-expect-error",
4
+ "@ts-nocheck",
5
+ "eslint-disable",
6
+ "eslint-enable",
7
+ "oxlint-disable",
8
+ "oxlint-enable",
9
+ ]
10
+
11
+ const errorNames = new Set([
12
+ "Error",
13
+ "AggregateError",
14
+ "EvalError",
15
+ "RangeError",
16
+ "ReferenceError",
17
+ "SyntaxError",
18
+ "TypeError",
19
+ "URIError",
20
+ ])
21
+
22
+ const rawSpecifiers = new Set([
23
+ "child_process",
24
+ "fs",
25
+ "http",
26
+ "https",
27
+ "node:child_process",
28
+ "node:fs",
29
+ "node:fs/promises",
30
+ "node:http",
31
+ "node:https",
32
+ ])
33
+
34
+ const socketSpecifiers = new Set(["net", "node:net", "node:tls", "tls"])
35
+
36
+ const consoleMethods = new Set(["debug", "error", "info", "log", "warn"])
37
+
38
+ const effectRunMethods = new Set(["runFork", "runPromise", "runPromiseExit", "runSync"])
39
+
40
+ const bannedTestMembers = new Set(["only", "skip", "skipIf", "todo"])
41
+
42
+ const testAliases = new Set(["fdescribe", "fit", "ftest", "xdescribe", "xit", "xtest"])
43
+
44
+ const report = (context, node, message) => {
45
+ context.report({ node, message })
46
+ }
47
+
48
+ const literalName = (property, computed) => {
49
+ if (!computed && property.type === "Identifier") {
50
+ return property.name
51
+ }
52
+ if (
53
+ computed &&
54
+ (property.type === "Literal" || property.type === "StringLiteral") &&
55
+ typeof property.value === "string"
56
+ ) {
57
+ return property.value
58
+ }
59
+ return ""
60
+ }
61
+
62
+ const isProcessEnv = (node) => {
63
+ if (node.type !== "MemberExpression") {
64
+ return false
65
+ }
66
+ return (
67
+ node.object.type === "Identifier" &&
68
+ node.object.name === "process" &&
69
+ literalName(node.property, node.computed) === "env"
70
+ )
71
+ }
72
+
73
+ const hasName = (node, name) => {
74
+ if (node === null || node === undefined || typeof node !== "object") {
75
+ return false
76
+ }
77
+ if (node.type === "Identifier" && node.name === name) {
78
+ return true
79
+ }
80
+ return (
81
+ hasName(node.callee, name) ||
82
+ hasName(node.object, name) ||
83
+ hasName(node.property, name) ||
84
+ hasName(node.expression, name)
85
+ )
86
+ }
87
+
88
+ const blessedSuper = (node) => hasName(node, "TaggedError") || hasName(node, "Service")
89
+
90
+ const specifier = (node) => {
91
+ if (node === null || node === undefined) {
92
+ return ""
93
+ }
94
+ if (
95
+ (node.type === "Literal" || node.type === "StringLiteral") &&
96
+ typeof node.value === "string"
97
+ ) {
98
+ return node.value
99
+ }
100
+ return ""
101
+ }
102
+
103
+ const chainParts = (callee) => {
104
+ const parts = []
105
+ let current = callee
106
+ while (current.type === "MemberExpression" || current.type === "ChainExpression") {
107
+ if (current.type === "ChainExpression") {
108
+ current = current.expression
109
+ continue
110
+ }
111
+ if (current.computed || current.property.type !== "Identifier") {
112
+ return []
113
+ }
114
+ parts.push(current.property.name)
115
+ current = current.object
116
+ }
117
+ if (current.type !== "Identifier") {
118
+ return []
119
+ }
120
+ parts.push(current.name)
121
+ parts.reverse()
122
+ return parts
123
+ }
124
+
125
+ const isItEffect = (parts) => {
126
+ if (parts[0] !== "it" || parts[1] !== "effect") {
127
+ return false
128
+ }
129
+ let index = 2
130
+ while (index < parts.length) {
131
+ const part = parts[index]
132
+ if (part !== undefined && bannedTestMembers.has(part)) {
133
+ return false
134
+ }
135
+ index += 1
136
+ }
137
+ return true
138
+ }
139
+
140
+ const flagModuleBinding = (context, statement) => {
141
+ if (statement === null || statement === undefined) {
142
+ return
143
+ }
144
+ if (statement.type === "VariableDeclaration" && statement.kind !== "const") {
145
+ report(
146
+ context,
147
+ statement,
148
+ "module-scope let and var are banned; keep state in a Context.Service",
149
+ )
150
+ }
151
+ const binding = statement.init ?? statement.left
152
+ if (
153
+ (statement.type === "ForStatement" ||
154
+ statement.type === "ForInStatement" ||
155
+ statement.type === "ForOfStatement") &&
156
+ binding !== null &&
157
+ binding !== undefined &&
158
+ binding.type === "VariableDeclaration" &&
159
+ binding.kind !== "const"
160
+ ) {
161
+ report(context, binding, "module-scope let and var are banned; keep state in a Context.Service")
162
+ }
163
+ }
164
+
165
+ const noThrow = {
166
+ meta: { type: "problem" },
167
+ create(context) {
168
+ return {
169
+ ThrowStatement(node) {
170
+ report(context, node, "throw is banned; yield a Schema.TaggedError")
171
+ },
172
+ }
173
+ },
174
+ }
175
+
176
+ const noTry = {
177
+ meta: { type: "problem" },
178
+ create(context) {
179
+ return {
180
+ TryStatement(node) {
181
+ report(context, node, "try is banned; failures stay on the Effect channel")
182
+ },
183
+ }
184
+ },
185
+ }
186
+
187
+ const noErrorClass = {
188
+ meta: { type: "problem" },
189
+ create(context) {
190
+ return {
191
+ NewExpression(node) {
192
+ const callee = node.callee
193
+ const name =
194
+ callee.type === "Identifier"
195
+ ? callee.name
196
+ : callee.type === "MemberExpression"
197
+ ? literalName(callee.property, callee.computed)
198
+ : ""
199
+ if (errorNames.has(name)) {
200
+ report(context, node, "new Error is banned; define a Schema.TaggedError")
201
+ }
202
+ },
203
+ }
204
+ },
205
+ }
206
+
207
+ const noProcessEnv = {
208
+ meta: { type: "problem" },
209
+ create(context) {
210
+ return {
211
+ MemberExpression(node) {
212
+ if (isProcessEnv(node)) {
213
+ report(
214
+ context,
215
+ node,
216
+ "process.env is banned; declare the variable in .env.schema and read it with Config",
217
+ )
218
+ }
219
+ },
220
+ }
221
+ },
222
+ }
223
+
224
+ const noTypeAssertion = {
225
+ meta: { type: "problem" },
226
+ create(context) {
227
+ return {
228
+ TSAsExpression(node) {
229
+ report(
230
+ context,
231
+ node,
232
+ "type assertions are banned, including as const; parse with Schema or use a const type parameter",
233
+ )
234
+ },
235
+ TSTypeAssertion(node) {
236
+ report(
237
+ context,
238
+ node,
239
+ "angle-bracket assertions are banned; parse with Schema or use a const type parameter",
240
+ )
241
+ },
242
+ }
243
+ },
244
+ }
245
+
246
+ const noExplicitAny = {
247
+ meta: { type: "problem" },
248
+ create(context) {
249
+ return {
250
+ TSAnyKeyword(node) {
251
+ report(context, node, "any is banned; use unknown and decode it with Schema")
252
+ },
253
+ }
254
+ },
255
+ }
256
+
257
+ const noNonNullAssertion = {
258
+ meta: { type: "problem" },
259
+ create(context) {
260
+ return {
261
+ TSNonNullExpression(node) {
262
+ report(
263
+ context,
264
+ node,
265
+ "non-null assertions are banned; narrow the value or decode it with Schema",
266
+ )
267
+ },
268
+ }
269
+ },
270
+ }
271
+
272
+ const textOf = (context) => {
273
+ const source = context.sourceCode
274
+ if (typeof source.text === "string") {
275
+ return source.text
276
+ }
277
+ if (typeof source.getText === "function") {
278
+ return source.getText()
279
+ }
280
+ return ""
281
+ }
282
+
283
+ const commentStart = (comment) => {
284
+ const range = comment.range
285
+ if (Array.isArray(range)) {
286
+ const start = range[0]
287
+ return typeof start === "number" ? start : -1
288
+ }
289
+ if (range !== null && range !== undefined && typeof range.start === "number") {
290
+ return range.start
291
+ }
292
+ if (typeof comment.start === "number") {
293
+ return comment.start
294
+ }
295
+ return -1
296
+ }
297
+
298
+ const isShebang = (comment, text) => {
299
+ if (comment.type === "Shebang" || comment.type === "Hashbang") {
300
+ return true
301
+ }
302
+ return text.startsWith("#!") && commentStart(comment) === 0
303
+ }
304
+
305
+ const noComments = {
306
+ meta: { type: "problem" },
307
+ create(context) {
308
+ const text = textOf(context)
309
+ return {
310
+ Program(node) {
311
+ for (const comment of context.sourceCode.getAllComments()) {
312
+ if (isShebang(comment, text)) {
313
+ continue
314
+ }
315
+ const message = "comments are banned; use clearer names or docs"
316
+ if (comment.loc === undefined || comment.loc === null) {
317
+ report(context, node, message)
318
+ } else {
319
+ context.report({ loc: comment.loc, message })
320
+ }
321
+ }
322
+ },
323
+ }
324
+ },
325
+ }
326
+
327
+ const noBypassComment = {
328
+ meta: { type: "problem" },
329
+ create(context) {
330
+ return {
331
+ Program(node) {
332
+ for (const comment of context.sourceCode.getAllComments()) {
333
+ let matched = ""
334
+ for (const marker of markers) {
335
+ if (comment.value.includes(marker)) {
336
+ matched = marker
337
+ break
338
+ }
339
+ }
340
+ if (matched === "") {
341
+ continue
342
+ }
343
+ const message = `directive comments are banned (${matched})`
344
+ if (comment.loc === undefined || comment.loc === null) {
345
+ report(context, node, message)
346
+ } else {
347
+ context.report({ loc: comment.loc, message })
348
+ }
349
+ }
350
+ },
351
+ }
352
+ },
353
+ }
354
+
355
+ const effectTests = {
356
+ meta: { type: "problem" },
357
+ create(context) {
358
+ const filename = typeof context.filename === "string" ? context.filename : ""
359
+ const isTest = filename.endsWith(".test.ts")
360
+ return {
361
+ ImportDeclaration(node) {
362
+ const source = specifier(node.source)
363
+ if (source === "vitest/config") {
364
+ if (!filename.endsWith("/vitest.config.ts") && !filename.endsWith("\\vitest.config.ts")) {
365
+ report(context, node, "import tests from @effect/vitest")
366
+ }
367
+ return
368
+ }
369
+ if (source === "vitest" || source.startsWith("vitest/") || source.startsWith("@vitest/")) {
370
+ report(context, node, "import tests from @effect/vitest")
371
+ }
372
+ },
373
+ CallExpression(node) {
374
+ if (!isTest) {
375
+ return
376
+ }
377
+ const parts = chainParts(node.callee)
378
+ const root = parts[0]
379
+ if (root === undefined) {
380
+ if (
381
+ node.callee.type === "Identifier" &&
382
+ (node.callee.name === "it" ||
383
+ node.callee.name === "test" ||
384
+ testAliases.has(node.callee.name))
385
+ ) {
386
+ report(context, node, "tests use it.effect from @effect/vitest")
387
+ }
388
+ return
389
+ }
390
+ if (isItEffect(parts)) {
391
+ return
392
+ }
393
+ if (root === "it" || root === "test" || testAliases.has(root)) {
394
+ report(context, node, "tests use it.effect from @effect/vitest")
395
+ return
396
+ }
397
+ if (root === "describe") {
398
+ let index = 1
399
+ while (index < parts.length) {
400
+ const part = parts[index]
401
+ if (part !== undefined && bannedTestMembers.has(part)) {
402
+ report(context, node, "skipped, focused, and todo tests are banned")
403
+ return
404
+ }
405
+ index += 1
406
+ }
407
+ }
408
+ },
409
+ }
410
+ },
411
+ }
412
+
413
+ const isAdapterFile = (context) => {
414
+ const filename = typeof context.filename === "string" ? context.filename : ""
415
+ return filename.endsWith(".adapter.ts")
416
+ }
417
+
418
+ const rawMessage = (source) => {
419
+ if (socketSpecifiers.has(source)) {
420
+ return "node:net and node:tls belong in a .adapter.ts file; other raw IO stays banned"
421
+ }
422
+ return "raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess"
423
+ }
424
+
425
+ const bannedRaw = (context, node, source) => {
426
+ if (socketSpecifiers.has(source)) {
427
+ if (isAdapterFile(context)) {
428
+ return
429
+ }
430
+ report(context, node, rawMessage(source))
431
+ return
432
+ }
433
+ if (rawSpecifiers.has(source)) {
434
+ report(context, node, rawMessage(source))
435
+ }
436
+ }
437
+
438
+ const noRawIo = {
439
+ meta: { type: "problem" },
440
+ create(context) {
441
+ return {
442
+ ImportDeclaration(node) {
443
+ bannedRaw(context, node, specifier(node.source))
444
+ },
445
+ ImportExpression(node) {
446
+ bannedRaw(context, node, specifier(node.source))
447
+ },
448
+ CallExpression(node) {
449
+ const callee = node.callee
450
+ if (callee.type === "Identifier" && callee.name === "fetch") {
451
+ report(context, node, "global fetch is banned; use Effect HttpClient")
452
+ return
453
+ }
454
+ if (callee.type === "Identifier" && callee.name === "require") {
455
+ bannedRaw(context, node, specifier(node.arguments[0]))
456
+ return
457
+ }
458
+ if (callee.type !== "MemberExpression") {
459
+ return
460
+ }
461
+ const name = literalName(callee.property, callee.computed)
462
+ if (
463
+ callee.object.type === "Identifier" &&
464
+ callee.object.name === "console" &&
465
+ consoleMethods.has(name)
466
+ ) {
467
+ report(context, node, "console is banned; use Effect Console")
468
+ return
469
+ }
470
+ if (
471
+ callee.object.type === "Identifier" &&
472
+ (callee.object.name === "globalThis" ||
473
+ callee.object.name === "global" ||
474
+ callee.object.name === "window") &&
475
+ name === "fetch"
476
+ ) {
477
+ report(context, node, "global fetch is banned; use Effect HttpClient")
478
+ }
479
+ },
480
+ }
481
+ },
482
+ }
483
+
484
+ const noEffectRun = {
485
+ meta: { type: "problem" },
486
+ create(context) {
487
+ return {
488
+ CallExpression(node) {
489
+ const callee = node.callee
490
+ if (callee.type !== "MemberExpression") {
491
+ return
492
+ }
493
+ if (
494
+ callee.object.type === "Identifier" &&
495
+ callee.object.name === "Effect" &&
496
+ effectRunMethods.has(literalName(callee.property, callee.computed))
497
+ ) {
498
+ report(
499
+ context,
500
+ node,
501
+ "Effect.run* is banned; stay inside Effect and use it.effect in tests",
502
+ )
503
+ }
504
+ },
505
+ }
506
+ },
507
+ }
508
+
509
+ const noModuleLet = {
510
+ meta: { type: "problem" },
511
+ create(context) {
512
+ return {
513
+ Program(node) {
514
+ for (const statement of node.body) {
515
+ const declaration =
516
+ statement.type === "ExportNamedDeclaration" ? statement.declaration : statement
517
+ flagModuleBinding(context, declaration)
518
+ }
519
+ },
520
+ }
521
+ },
522
+ }
523
+
524
+ const noPlainClass = {
525
+ meta: { type: "problem" },
526
+ create(context) {
527
+ return {
528
+ ClassDeclaration(node) {
529
+ if (!blessedSuper(node.superClass)) {
530
+ report(
531
+ context,
532
+ node,
533
+ "plain classes are banned; extend Schema.TaggedError or Context.Service",
534
+ )
535
+ }
536
+ },
537
+ ClassExpression(node) {
538
+ if (!blessedSuper(node.superClass)) {
539
+ report(
540
+ context,
541
+ node,
542
+ "plain classes are banned; extend Schema.TaggedError or Context.Service",
543
+ )
544
+ }
545
+ },
546
+ }
547
+ },
548
+ }
549
+
550
+ const rules = {
551
+ "effect-tests": effectTests,
552
+ "no-bypass-comment": noBypassComment,
553
+ "no-comments": noComments,
554
+ "no-effect-run": noEffectRun,
555
+ "no-error-class": noErrorClass,
556
+ "no-explicit-any": noExplicitAny,
557
+ "no-module-let": noModuleLet,
558
+ "no-non-null-assertion": noNonNullAssertion,
559
+ "no-plain-class": noPlainClass,
560
+ "no-process-env": noProcessEnv,
561
+ "no-raw-io": noRawIo,
562
+ "no-throw": noThrow,
563
+ "no-try": noTry,
564
+ "no-type-assertion": noTypeAssertion,
565
+ }
566
+
567
+ export const ruleNames = Object.keys(rules)
568
+
569
+ export default {
570
+ meta: { name: "aas" },
571
+ rules,
572
+ }