| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as PlatformError from "effect/PlatformError"; |
| 8 | import * as Predicate from "effect/Predicate"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 12 | import * as NodeModule from "node:module"; |
| 13 | |
| 14 | // oxlint is only a transitive dependency (via vite-plus), so its bin placement |
| 15 | // varies by package manager: pnpm hoists it into the virtual store, while |
| 16 | // other layouts expose vite-plus's LSP-only wrapper instead. Resolve the real |
| 17 | // package through vite-plus rather than hardcoding either layout, and do it at |
| 18 | // module scope so a broken install fails once with a resolution error instead |
| 19 | // of as an opaque defect in every test. |
| 20 | const oxlintPackageJsonPath = NodeModule.createRequire( |
| 21 | NodeModule.createRequire(import.meta.url).resolve("vite-plus/package.json"), |
| 22 | ).resolve("oxlint/package.json"); |
| 23 | |
| 24 | class OxlintFixtureFailure extends Data.TaggedError("OxlintFixtureFailure")<{ |
| 25 | readonly exitCode: number; |
| 26 | readonly stdout: string; |
| 27 | readonly stderr: string; |
| 28 | }> { |
| 29 | static readonly is = (u: unknown): u is OxlintFixtureFailure => |
| 30 | Predicate.isTagged(u, "OxlintFixtureFailure"); |
| 31 | } |
| 32 | |
| 33 | class OxlintFixtureExpectedFailure extends Data.TaggedError("OxlintFixtureExpectedFailure")<{ |
| 34 | readonly ruleName: string; |
| 35 | }> { |
| 36 | override get message() { |
| 37 | return `Expected oxlint to report a failure for rule ${this.ruleName}, but it passed.`; |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | const encodeOxlintConfig = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 42 | |
| 43 | interface RuleHarness { |
| 44 | readonly run: ( |
| 45 | source: string, |
| 46 | ) => Effect.Effect< |
| 47 | string, |
| 48 | OxlintFixtureFailure | PlatformError.PlatformError | Schema.SchemaError, |
| 49 | NodeServices.NodeServices |
| 50 | >; |
| 51 | readonly runAndExpectFailure: ( |
| 52 | source: string, |
| 53 | ) => Effect.Effect< |
| 54 | string, |
| 55 | OxlintFixtureExpectedFailure | PlatformError.PlatformError | Schema.SchemaError, |
| 56 | NodeServices.NodeServices |
| 57 | >; |
| 58 | readonly valid: (name: string, source: string) => void; |
| 59 | readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void; |
| 60 | } |
| 61 | |
| 62 | interface RuleHarnessOptions { |
| 63 | readonly filename?: string; |
| 64 | /** Rule options, as they would appear after the severity in the lint config. */ |
| 65 | readonly ruleOptions?: ReadonlyArray<unknown>; |
| 66 | } |
| 67 | |
| 68 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => |
| 69 | stream.pipe( |
| 70 | Stream.decodeText(), |
| 71 | Stream.runFold( |
| 72 | () => "", |
| 73 | (acc, chunk) => acc + chunk, |
| 74 | ), |
| 75 | ); |
| 76 | |
| 77 | const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess.Command) { |
| 78 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 79 | const child = yield* spawner.spawn(command); |
| 80 | |
| 81 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 82 | [ |
| 83 | collectStreamAsString(child.stdout), |
| 84 | collectStreamAsString(child.stderr), |
| 85 | child.exitCode.pipe(Effect.map(Number)), |
| 86 | ], |
| 87 | { concurrency: "unbounded" }, |
| 88 | ); |
| 89 | |
| 90 | return { exitCode, stdout, stderr }; |
| 91 | }, Effect.scoped); |
| 92 | |
| 93 | export const createOxlintRuleHarness = ( |
| 94 | ruleName: string, |
| 95 | options: RuleHarnessOptions = {}, |
| 96 | ): RuleHarness => { |
| 97 | const [pluginName, shortRuleName] = ruleName.split("/"); |
| 98 | const diagnosticRuleName = |
| 99 | pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName; |
| 100 | const test = it.layer(NodeServices.layer); |
| 101 | |
| 102 | const run: RuleHarness["run"] = Effect.fnUntraced(function* (source: string) { |
| 103 | const fs = yield* FileSystem.FileSystem; |
| 104 | const path = yield* Path.Path; |
| 105 | const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" }); |
| 106 | const configPath = path.join(fixtureDir, ".oxlintrc.json"); |
| 107 | const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts"); |
| 108 | const repoRoot = path.join(import.meta.dirname, "..", ".."); |
| 109 | const oxlintBin = path.join(path.dirname(oxlintPackageJsonPath), "bin", "oxlint"); |
| 110 | const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts"); |
| 111 | |
| 112 | yield* fs.writeFileString( |
| 113 | configPath, |
| 114 | yield* encodeOxlintConfig({ |
| 115 | jsPlugins: [{ name: "t3code", specifier: pluginPath }], |
| 116 | rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] }, |
| 117 | }), |
| 118 | ); |
| 119 | yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); |
| 120 | yield* fs.writeFileString(sourcePath, source); |
| 121 | |
| 122 | // Run through the current Node binary: oxlint's bin is an extensionless |
| 123 | // shebang script, which Windows cannot spawn directly and which would |
| 124 | // otherwise pick up whatever node is first on PATH. |
| 125 | const output = yield* spawnAndCollectOutput( |
| 126 | ChildProcess.make(process.execPath, [oxlintBin, "--config", configPath, sourcePath], { |
| 127 | cwd: repoRoot, |
| 128 | }), |
| 129 | ); |
| 130 | |
| 131 | if (output.exitCode !== 0) { |
| 132 | return yield* new OxlintFixtureFailure({ |
| 133 | exitCode: output.exitCode, |
| 134 | stdout: output.stdout, |
| 135 | stderr: output.stderr, |
| 136 | }); |
| 137 | } |
| 138 | |
| 139 | return `${output.stdout}${output.stderr}`; |
| 140 | }, Effect.scoped); |
| 141 | |
| 142 | const runAndExpectFailure: RuleHarness["runAndExpectFailure"] = (source) => |
| 143 | run(source).pipe( |
| 144 | Effect.matchEffect({ |
| 145 | onFailure: (error) => |
| 146 | OxlintFixtureFailure.is(error) |
| 147 | ? Effect.succeed( |
| 148 | `oxlint fixture failed with exit code ${error.exitCode}\n${error.stdout}\n${error.stderr}`, |
| 149 | ) |
| 150 | : Effect.fail(error), |
| 151 | onSuccess: () => Effect.fail(new OxlintFixtureExpectedFailure({ ruleName })), |
| 152 | }), |
| 153 | ); |
| 154 | |
| 155 | return { |
| 156 | run, |
| 157 | runAndExpectFailure, |
| 158 | valid(name, source) { |
| 159 | test(name, (it) => { |
| 160 | it.effect("passes", () => run(source)); |
| 161 | }); |
| 162 | }, |
| 163 | invalid(name, source, assertion) { |
| 164 | test(name, (it) => { |
| 165 | it.effect("reports the rule diagnostic", () => |
| 166 | runAndExpectFailure(source).pipe( |
| 167 | Effect.tap((output) => |
| 168 | Effect.sync(() => { |
| 169 | assert.match(output, new RegExp(diagnosticRuleName)); |
| 170 | assertion?.(output); |
| 171 | }), |
| 172 | ), |
| 173 | ), |
| 174 | ); |
| 175 | }); |
| 176 | }, |
| 177 | }; |
| 178 | }; |