| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 6 | import * as Console from "effect/Console"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Logger from "effect/Logger"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import { Command } from "effect/cli"; |
| 13 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 14 | |
| 15 | interface NativeStaticTool { |
| 16 | readonly command: string; |
| 17 | readonly installHint: string; |
| 18 | } |
| 19 | |
| 20 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 21 | |
| 22 | export class NativeStaticCheckSourceDiscoveryError extends Schema.TaggedError<NativeStaticCheckSourceDiscoveryError>()( |
| 23 | "NativeStaticCheckSourceDiscoveryError", |
| 24 | { |
| 25 | operation: Schema.Literals(["resolve-root", "read-directory", "stat-entry"]), |
| 26 | path: Schema.String, |
| 27 | cause: Schema.Defect(), |
| 28 | }, |
| 29 | ) { |
| 30 | override get message(): string { |
| 31 | return `Native source discovery operation '${this.operation}' failed.`; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | export class NativeStaticCheckProcessError extends Schema.TaggedError<NativeStaticCheckProcessError>()( |
| 36 | "NativeStaticCheckProcessError", |
| 37 | { |
| 38 | operation: Schema.Literals(["spawn", "wait-for-exit"]), |
| 39 | command: Schema.String, |
| 40 | argumentCount: NonNegativeInt, |
| 41 | cwd: Schema.String, |
| 42 | shell: Schema.Boolean, |
| 43 | cause: Schema.Defect(), |
| 44 | }, |
| 45 | ) { |
| 46 | override get message(): string { |
| 47 | return `Native static check process operation '${this.operation}' failed for command '${this.command}'.`; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | export class NativeStaticCheckCommandError extends Schema.TaggedError<NativeStaticCheckCommandError>()( |
| 52 | "NativeStaticCheckCommandError", |
| 53 | { |
| 54 | command: Schema.String, |
| 55 | argumentCount: NonNegativeInt, |
| 56 | cwd: Schema.String, |
| 57 | shell: Schema.Boolean, |
| 58 | exitCode: Schema.Int, |
| 59 | }, |
| 60 | ) { |
| 61 | override get message(): string { |
| 62 | return `Native static check command '${this.command}' exited with code ${this.exitCode}.`; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | const tools = [ |
| 67 | { |
| 68 | command: "swiftlint", |
| 69 | installHint: "brew install swiftlint", |
| 70 | }, |
| 71 | { |
| 72 | command: "ktlint", |
| 73 | installHint: "brew install ktlint", |
| 74 | }, |
| 75 | { |
| 76 | command: "detekt", |
| 77 | installHint: "brew install detekt", |
| 78 | }, |
| 79 | ] as const satisfies ReadonlyArray<NativeStaticTool>; |
| 80 | |
| 81 | const sourceExtensions = new Set([".swift", ".kt", ".kts"]); |
| 82 | const excludedDirectories = new Set([ |
| 83 | ".expo", |
| 84 | ".git", |
| 85 | "build", |
| 86 | "DerivedData", |
| 87 | "node_modules", |
| 88 | "Pods", |
| 89 | "Vendor", |
| 90 | ]); |
| 91 | const generatedNativeProjectDirectories = new Set(["android", "ios"]); |
| 92 | |
| 93 | const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url); |
| 94 | const appRoot = Effect.service(Path.Path).pipe( |
| 95 | Effect.flatMap((path) => path.fromFileUrl(mobileAppRootUrl)), |
| 96 | Effect.mapError( |
| 97 | (cause) => |
| 98 | new NativeStaticCheckSourceDiscoveryError({ |
| 99 | operation: "resolve-root", |
| 100 | path: mobileAppRootUrl.pathname, |
| 101 | cause, |
| 102 | }), |
| 103 | ), |
| 104 | ); |
| 105 | |
| 106 | const commandOutputOptions = { |
| 107 | stdout: "inherit", |
| 108 | stderr: "inherit", |
| 109 | } as const; |
| 110 | |
| 111 | const commandExists = Effect.fn("commandExists")(function* (command: string) { |
| 112 | return yield* isCommandAvailable(command); |
| 113 | }); |
| 114 | |
| 115 | const warnMissingTool = (tool: NativeStaticTool, checkName: string) => |
| 116 | Effect.logWarning( |
| 117 | `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`, |
| 118 | ); |
| 119 | |
| 120 | export const runCommand = Effect.fn("runCommand")(function* ( |
| 121 | command: string, |
| 122 | args: ReadonlyArray<string>, |
| 123 | cwd: string, |
| 124 | ) { |
| 125 | yield* Console.log(`$ ${[command, ...args].join(" ")}`); |
| 126 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 127 | const spawnCommand = yield* resolveSpawnCommand(command, args); |
| 128 | const processContext = { |
| 129 | command, |
| 130 | argumentCount: args.length, |
| 131 | cwd, |
| 132 | shell: spawnCommand.shell, |
| 133 | } as const; |
| 134 | const child = yield* spawner |
| 135 | .spawn( |
| 136 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 137 | cwd, |
| 138 | ...commandOutputOptions, |
| 139 | shell: spawnCommand.shell, |
| 140 | }), |
| 141 | ) |
| 142 | .pipe( |
| 143 | Effect.mapError( |
| 144 | (cause) => |
| 145 | new NativeStaticCheckProcessError({ |
| 146 | ...processContext, |
| 147 | operation: "spawn", |
| 148 | cause, |
| 149 | }), |
| 150 | ), |
| 151 | ); |
| 152 | const exitCode = Number( |
| 153 | yield* child.exitCode.pipe( |
| 154 | Effect.mapError( |
| 155 | (cause) => |
| 156 | new NativeStaticCheckProcessError({ |
| 157 | ...processContext, |
| 158 | operation: "wait-for-exit", |
| 159 | cause, |
| 160 | }), |
| 161 | ), |
| 162 | ), |
| 163 | ); |
| 164 | |
| 165 | if (exitCode !== 0) { |
| 166 | return yield* new NativeStaticCheckCommandError({ |
| 167 | ...processContext, |
| 168 | exitCode, |
| 169 | }); |
| 170 | } |
| 171 | }); |
| 172 | |
| 173 | export function collectSources( |
| 174 | directory: string, |
| 175 | root: string, |
| 176 | ): Effect.Effect< |
| 177 | ReadonlyArray<string>, |
| 178 | NativeStaticCheckSourceDiscoveryError, |
| 179 | FileSystem.FileSystem | Path.Path |
| 180 | > { |
| 181 | return Effect.gen(function* () { |
| 182 | const fs = yield* FileSystem.FileSystem; |
| 183 | const path = yield* Path.Path; |
| 184 | const entries = yield* fs.readDirectory(directory).pipe( |
| 185 | Effect.mapError( |
| 186 | (cause) => |
| 187 | new NativeStaticCheckSourceDiscoveryError({ |
| 188 | operation: "read-directory", |
| 189 | path: directory, |
| 190 | cause, |
| 191 | }), |
| 192 | ), |
| 193 | ); |
| 194 | const sources: Array<string> = []; |
| 195 | |
| 196 | for (const entry of entries) { |
| 197 | const entryPath = path.join(directory, entry); |
| 198 | const stat = yield* fs.stat(entryPath).pipe( |
| 199 | Effect.mapError( |
| 200 | (cause) => |
| 201 | new NativeStaticCheckSourceDiscoveryError({ |
| 202 | operation: "stat-entry", |
| 203 | path: entryPath, |
| 204 | cause, |
| 205 | }), |
| 206 | ), |
| 207 | ); |
| 208 | |
| 209 | if (stat.type === "Directory") { |
| 210 | const isGeneratedNativeProjectDirectory = |
| 211 | directory === root && generatedNativeProjectDirectories.has(entry); |
| 212 | |
| 213 | if (excludedDirectories.has(entry) || isGeneratedNativeProjectDirectory) { |
| 214 | continue; |
| 215 | } |
| 216 | |
| 217 | sources.push(...(yield* collectSources(entryPath, root))); |
| 218 | continue; |
| 219 | } |
| 220 | |
| 221 | if (stat.type === "File" && sourceExtensions.has(path.extname(entry))) { |
| 222 | sources.push(entryPath); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | return sources; |
| 227 | }); |
| 228 | } |
| 229 | |
| 230 | const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () { |
| 231 | const path = yield* Path.Path; |
| 232 | const root = yield* appRoot; |
| 233 | const sources = yield* collectSources(root, root); |
| 234 | const swiftSources = sources.filter((source) => path.extname(source) === ".swift"); |
| 235 | const kotlinSources = sources.filter((source) => { |
| 236 | const extension = path.extname(source); |
| 237 | return extension === ".kt" || extension === ".kts"; |
| 238 | }); |
| 239 | const availableTools = new Map<string, boolean>(); |
| 240 | |
| 241 | for (const tool of tools) { |
| 242 | availableTools.set(tool.command, yield* commandExists(tool.command)); |
| 243 | } |
| 244 | |
| 245 | yield* Console.log( |
| 246 | `Found ${swiftSources.length} Swift and ${kotlinSources.length} Kotlin native source files.`, |
| 247 | ); |
| 248 | |
| 249 | if (swiftSources.length > 0) { |
| 250 | if (availableTools.get("swiftlint")) { |
| 251 | yield* runCommand("swiftlint", ["lint", "--config", ".swiftlint.yml", "--strict"], root); |
| 252 | } else { |
| 253 | yield* warnMissingTool(tools[0], "SwiftLint"); |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | if (kotlinSources.length > 0) { |
| 258 | const relativeKotlinSources = kotlinSources.map((source) => path.relative(root, source)); |
| 259 | |
| 260 | if (availableTools.get("ktlint")) { |
| 261 | yield* runCommand("ktlint", relativeKotlinSources, root); |
| 262 | } else { |
| 263 | yield* warnMissingTool(tools[1], "ktlint"); |
| 264 | } |
| 265 | |
| 266 | if (availableTools.get("detekt")) { |
| 267 | yield* runCommand( |
| 268 | "detekt", |
| 269 | [ |
| 270 | "--config", |
| 271 | "detekt.yml", |
| 272 | "--input", |
| 273 | relativeKotlinSources.join(","), |
| 274 | "--build-upon-default-config", |
| 275 | ], |
| 276 | root, |
| 277 | ); |
| 278 | } else { |
| 279 | yield* warnMissingTool(tools[2], "detekt"); |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | yield* Console.log("Skipping generated native project folders: android/, ios/."); |
| 284 | }); |
| 285 | |
| 286 | export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () => |
| 287 | runNativeStaticChecks(), |
| 288 | ).pipe( |
| 289 | Command.withDescription("Run mobile native static analysis when native tools are available."), |
| 290 | ); |
| 291 | |
| 292 | if (import.meta.main) { |
| 293 | Command.run(mobileNativeStaticCheckCommand, { version: "0.0.0" }).pipe( |
| 294 | Effect.scoped, |
| 295 | Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]), |
| 296 | NodeRuntime.runMain, |
| 297 | ); |
| 298 | } |