1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import { Command } from "effect/cli";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14
15interface NativeStaticTool {
16 readonly command: string;
17 readonly installHint: string;
18}
19
20const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
21
22export 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
35export 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
51export 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
66const 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
81const sourceExtensions = new Set([".swift", ".kt", ".kts"]);
82const excludedDirectories = new Set([
83 ".expo",
84 ".git",
85 "build",
86 "DerivedData",
87 "node_modules",
88 "Pods",
89 "Vendor",
90]);
91const generatedNativeProjectDirectories = new Set(["android", "ios"]);
92
93const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url);
94const 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
106const commandOutputOptions = {
107 stdout: "inherit",
108 stderr: "inherit",
109} as const;
110
111const commandExists = Effect.fn("commandExists")(function* (command: string) {
112 return yield* isCommandAvailable(command);
113});
114
115const 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
120export 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
173export 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
230const 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
286export 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
292if (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}