Skip to content
Source
Sign inSign up

mobile-native-static-check.ts

319 lines9.2 KB

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;
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 }
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 }
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 }
65​
66const kotlinToolCommands = ["ktlint", "detekt"] as const;
67​
68export class NativeStaticCheckMissingToolError extends Schema.TaggedError<NativeStaticCheckMissingToolError>()(
69 "NativeStaticCheckMissingToolError",
70 {
71 commands: Schema.Array(Schema.Literals(kotlinToolCommands)),
72 },
73) {
74 override get message(): string {
75 const names = this.commands.join(" and ");
76 const verb = this.commands.length === 1 ? "is" : "are";
77 return `${names} ${verb} not installed; cannot run Kotlin static analysis. Install via \`brew bundle install --file apps/mobile/Brewfile\` or put the JVM tools on PATH.`;
78 }
80​
81const tools = [
82 {
83 command: "swiftlint",
84 installHint: "brew install swiftlint",
85 },
86 {
87 command: "ktlint",
88 installHint: "brew install ktlint",
89 },
90 {
91 command: "detekt",
92 installHint: "brew install detekt",
93 },
94] as const satisfies ReadonlyArray<NativeStaticTool>;
95​
96const sourceExtensions = new Set([".swift", ".kt", ".kts"]);
97const excludedDirectories = new Set([
98 ".expo",
99 ".git",
100 "build",
101 "DerivedData",
102 "nitrogen",
103 "node_modules",
104 "Pods",
105 "Vendor",
106]);
107const generatedNativeProjectDirectories = new Set(["android", "ios"]);
108​
109const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url);
110const appRoot = Effect.service(Path.Path).pipe(
111 Effect.flatMap((path) => path.fromFileUrl(mobileAppRootUrl)),
112 Effect.mapError(
113 (cause) =>
114 new NativeStaticCheckSourceDiscoveryError({
115 operation: "resolve-root",
116 path: mobileAppRootUrl.pathname,
117 cause,
118 }),
119 ),
120);
121​
122const commandOutputOptions = {
123 stdout: "inherit",
124 stderr: "inherit",
125} as const;
126​
127const commandExists = Effect.fn("commandExists")(function* (command: string) {
128 return yield* isCommandAvailable(command);
129});
130​
131const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>
132 Effect.logWarning(
133 `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`,
134 );
135​
136export const requireKotlinTools = Effect.fn("requireKotlinTools")(function* (available: {
137 readonly ktlint: boolean;
138 readonly detekt: boolean;
139}) {
140 const missing = kotlinToolCommands.filter((command) => !available[command]);
141 if (missing.length > 0) {
142 return yield* new NativeStaticCheckMissingToolError({ commands: missing });
143 }
144});
145​
146export const runCommand = Effect.fn("runCommand")(function* (
147 command: string,
148 args: ReadonlyArray<string>,
149 cwd: string,
150) {
151 yield* Console.log(`$ ${[command, ...args].join(" ")}`);
152 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
153 const spawnCommand = yield* resolveSpawnCommand(command, args);
154 const processContext = {
155 command,
156 argumentCount: args.length,
157 cwd,
158 shell: spawnCommand.shell,
159 } as const;
160 const child = yield* spawner
161 .spawn(
162 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
163 cwd,
164 ...commandOutputOptions,
165 shell: spawnCommand.shell,
166 }),
167 )
168 .pipe(
169 Effect.mapError(
170 (cause) =>
171 new NativeStaticCheckProcessError({
172 ...processContext,
173 operation: "spawn",
174 cause,
175 }),
176 ),
177 );
178 const exitCode = Number(
179 yield* child.exitCode.pipe(
180 Effect.mapError(
181 (cause) =>
182 new NativeStaticCheckProcessError({
183 ...processContext,
184 operation: "wait-for-exit",
185 cause,
186 }),
187 ),
188 ),
189 );
190​
191 if (exitCode !== 0) {
192 return yield* new NativeStaticCheckCommandError({
193 ...processContext,
194 exitCode,
195 });
196 }
197});
198​
199export const collectSources = Effect.fn("mobile-native-static-check.collectSources")(function* (
200 directory: string,
201 root: string,
202): Effect.fn.Return<
203 ReadonlyArray<string>,
204 NativeStaticCheckSourceDiscoveryError,
205 FileSystem.FileSystem | Path.Path
206> {
207 const fs = yield* FileSystem.FileSystem;
208 const path = yield* Path.Path;
209 const entries = yield* fs.readDirectory(directory).pipe(
210 Effect.mapError(
211 (cause) =>
212 new NativeStaticCheckSourceDiscoveryError({
213 operation: "read-directory",
214 path: directory,
215 cause,
216 }),
217 ),
218 );
219 const sources: Array<string> = [];
220​
221 for (const entry of entries) {
222 const entryPath = path.join(directory, entry);
223 const stat = yield* fs.stat(entryPath).pipe(
224 Effect.mapError(
225 (cause) =>
226 new NativeStaticCheckSourceDiscoveryError({
227 operation: "stat-entry",
228 path: entryPath,
229 cause,
230 }),
231 ),
232 );
233​
234 if (stat.type === "Directory") {
235 const isGeneratedNativeProjectDirectory =
236 directory === root && generatedNativeProjectDirectories.has(entry);
237​
238 if (excludedDirectories.has(entry) || isGeneratedNativeProjectDirectory) {
239 continue;
240 }
241​
242 sources.push(...(yield* collectSources(entryPath, root)));
243 continue;
244 }
245​
246 if (stat.type === "File" && sourceExtensions.has(path.extname(entry))) {
247 sources.push(entryPath);
248 }
249 }
250​
251 return sources;
252});
253​
254const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () {
255 const path = yield* Path.Path;
256 const root = yield* appRoot;
257 const sources = yield* collectSources(root, root);
258 const swiftSources = sources.filter((source) => path.extname(source) === ".swift");
259 const kotlinSources = sources.filter((source) => {
260 const extension = path.extname(source);
261 return extension === ".kt" || extension === ".kts";
262 });
263 const availableTools = new Map<string, boolean>();
264​
265 for (const tool of tools) {
266 availableTools.set(tool.command, yield* commandExists(tool.command));
267 }
268​
269 yield* Console.log(
270 `Found ${swiftSources.length} Swift and ${kotlinSources.length} Kotlin native source files.`,
271 );
272​
273 if (swiftSources.length > 0) {
274 if (availableTools.get("swiftlint")) {
275 yield* runCommand("swiftlint", ["lint", "--config", ".swiftlint.yml", "--strict"], root);
276 } else {
277 yield* warnMissingTool(tools[0], "SwiftLint");
278 }
279 }
280​
281 if (kotlinSources.length > 0) {
282 yield* requireKotlinTools({
283 ktlint: availableTools.get("ktlint") === true,
284 detekt: availableTools.get("detekt") === true,
285 });
286​
287 const relativeKotlinSources = kotlinSources.map((source) => path.relative(root, source));
288 yield* runCommand("ktlint", relativeKotlinSources, root);
289 yield* runCommand(
290 "detekt",
291 [
292 "--config",
293 "detekt.yml",
294 "--input",
295 relativeKotlinSources.join(","),
296 "--build-upon-default-config",
297 ],
298 root,
299 );
300 }
301​
302 yield* Console.log("Skipping generated native project folders: android/, ios/.");
303});
304​
305export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () =>
306 runNativeStaticChecks(),
307).pipe(
308 Command.withDescription(
309 "Run mobile native static analysis. SwiftLint may be skipped when unavailable; ktlint and detekt are required when Kotlin sources exist.",
310 ),
311);
312​
313if (import.meta.main) {
314 Command.run(mobileNativeStaticCheckCommand, { version: "0.0.0" }).pipe(
315 Effect.scoped,
316 Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]),
317 NodeRuntime.runMain,
318 );