authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 18:58:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 18:58:16-07:00
log6fc79737c3854344d05c1ac3d0dd10e143a19fa9
tree8fe51d06d8d24498c0274b75e8b1f1eefb05b255
parentcdd03591efb09baf06299e3764ddbd01c313a936
signature Signed by PGP key B5690EEEBB952194

[codex] Structure mobile native static-check failures (#3464)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 238 insertions(+), 30 deletions(-)

scripts/mobile-native-static-check.test.ts+136-12
...@@ -1,18 +1,142 @@...@@ -1,18 +1,142 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as HostProcess from "@t3tools/shared/hostProcess";
1import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
11import { ChildProcessSpawner } from "effect/unstable/process";
212
3import { NativeStaticCheckCommandError } from "./mobile-native-static-check.ts";13import { collectSources, runCommand } from "./mobile-native-static-check.ts";
414
5it("describes failed native static-analysis commands structurally", () => {15const processHandle = (
6 const error = new NativeStaticCheckCommandError({16 exitCode: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
7 command: "swiftlint",17) =>
8 args: ["lint", "--strict"],18 ChildProcessSpawner.makeHandle({
9 cwd: "/repo/apps/mobile",19 pid: ChildProcessSpawner.ProcessId(1),
10 exitCode: 2,20 exitCode,
21 isRunning: Effect.succeed(false),
22 kill: () => Effect.void,
23 unref: Effect.succeed(Effect.void),
24 stdin: Sink.drain,
25 stdout: Stream.empty,
26 stderr: Stream.empty,
27 all: Stream.empty,
28 getInputFd: () => Sink.drain,
29 getOutputFd: () => Stream.empty,
11 });30 });
1231
13 assert.equal(error.command, "swiftlint");32const provideSpawner = (spawn: ChildProcessSpawner.ChildProcessSpawner["Service"]["spawn"]) =>
14 assert.deepStrictEqual(error.args, ["lint", "--strict"]);33 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(spawn));
15 assert.equal(error.cwd, "/repo/apps/mobile");34
16 assert.equal(error.exitCode, 2);35const runSwiftLint = runCommand("swiftlint", ["lint", "--strict"], "/repo/apps/mobile").pipe(
17 assert.equal(error.message, "Native static check command 'swiftlint' exited with code 2.");36 Effect.provideService(HostProcess.HostProcessPlatform, "linux"),
37);
38
39it.layer(NodeServices.layer)("mobile native source discovery", (it) => {
40 it.effect("preserves the failed discovery operation, path, and exact cause", () =>
41 Effect.gen(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const root = yield* fs.makeTempDirectoryScoped({ prefix: "mobile-native-static-check-" });
45 const missingDirectory = path.join(root, "missing");
46
47 const error = yield* collectSources(missingDirectory, root).pipe(Effect.flip);
48
49 assert.equal(error._tag, "NativeStaticCheckSourceDiscoveryError");
50 assert.equal(error.operation, "read-directory");
51 assert.equal(error.path, missingDirectory);
52 assert.instanceOf(error.cause, PlatformError.PlatformError);
53 assert.equal(error.message, "Native source discovery operation 'read-directory' failed.");
54 }),
55 );
56});
57
58it.effect("preserves process spawn context and the exact cause", () => {
59 const cause = PlatformError.systemError({
60 _tag: "NotFound",
61 module: "ChildProcess",
62 method: "spawn",
63 description: "swiftlint was not found",
64 });
65
66 return Effect.gen(function* () {
67 const error = yield* runSwiftLint.pipe(
68 Effect.provide(provideSpawner(() => Effect.fail(cause))),
69 Effect.flip,
70 );
71
72 if (error._tag !== "NativeStaticCheckProcessError") {
73 return assert.fail(`Unexpected error: ${error._tag}`);
74 }
75 assert.equal(error.operation, "spawn");
76 assert.equal(error.command, "swiftlint");
77 assert.equal(error.argumentCount, 2);
78 assert.equal(error.cwd, "/repo/apps/mobile");
79 assert.equal(error.shell, false);
80 assert.equal(error.cause, cause);
81 assert.equal(
82 error.message,
83 "Native static check process operation 'spawn' failed for command 'swiftlint'.",
84 );
85 assert.notProperty(error, "args");
86 });
18});87});
88
89it.effect("preserves process wait context and the exact cause", () => {
90 const cause = PlatformError.systemError({
91 _tag: "Unknown",
92 module: "ChildProcess",
93 method: "exitCode",
94 description: "status unavailable",
95 });
96
97 return Effect.gen(function* () {
98 const error = yield* runSwiftLint.pipe(
99 Effect.provide(provideSpawner(() => Effect.succeed(processHandle(Effect.fail(cause))))),
100 Effect.flip,
101 );
102
103 if (error._tag !== "NativeStaticCheckProcessError") {
104 return assert.fail(`Unexpected error: ${error._tag}`);
105 }
106 assert.equal(error.operation, "wait-for-exit");
107 assert.equal(error.command, "swiftlint");
108 assert.equal(error.argumentCount, 2);
109 assert.equal(error.cwd, "/repo/apps/mobile");
110 assert.equal(error.shell, false);
111 assert.equal(error.cause, cause);
112 assert.equal(
113 error.message,
114 "Native static check process operation 'wait-for-exit' failed for command 'swiftlint'.",
115 );
116 assert.notProperty(error, "args");
117 });
118});
119
120it.effect("reports non-zero exits without manufacturing a cause", () =>
121 Effect.gen(function* () {
122 const error = yield* runSwiftLint.pipe(
123 Effect.provide(
124 provideSpawner(() =>
125 Effect.succeed(processHandle(Effect.succeed(ChildProcessSpawner.ExitCode(2)))),
126 ),
127 ),
128 Effect.flip,
129 );
130
131 if (error._tag !== "NativeStaticCheckCommandError") {
132 return assert.fail(`Unexpected error: ${error._tag}`);
133 }
134 assert.equal(error.command, "swiftlint");
135 assert.equal(error.argumentCount, 2);
136 assert.equal(error.cwd, "/repo/apps/mobile");
137 assert.equal(error.shell, false);
138 assert.equal(error.exitCode, 2);
139 assert.notProperty(error, "cause");
140 assert.notProperty(error, "args");
141 }),
142);
scripts/mobile-native-static-check.ts+102-18
...@@ -8,7 +8,6 @@ import * as Effect from "effect/Effect";...@@ -8,7 +8,6 @@ import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Logger from "effect/Logger";9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
13import { Command } from "effect/unstable/cli";12import { Command } from "effect/unstable/cli";
14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
...@@ -18,12 +17,44 @@ interface NativeStaticTool {...@@ -18,12 +17,44 @@ interface NativeStaticTool {
18 readonly installHint: string;17 readonly installHint: string;
19}18}
2019
20const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
21
22export class NativeStaticCheckSourceDiscoveryError extends Schema.TaggedErrorClass<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.TaggedErrorClass<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
21export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass<NativeStaticCheckCommandError>()(51export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass<NativeStaticCheckCommandError>()(
22 "NativeStaticCheckCommandError",52 "NativeStaticCheckCommandError",
23 {53 {
24 command: Schema.String,54 command: Schema.String,
25 args: Schema.Array(Schema.String),55 argumentCount: NonNegativeInt,
26 cwd: Schema.String,56 cwd: Schema.String,
57 shell: Schema.Boolean,
27 exitCode: Schema.Int,58 exitCode: Schema.Int,
28 },59 },
29) {60) {
...@@ -59,8 +90,17 @@ const excludedDirectories = new Set([...@@ -59,8 +90,17 @@ const excludedDirectories = new Set([
59]);90]);
60const generatedNativeProjectDirectories = new Set(["android", "ios"]);91const generatedNativeProjectDirectories = new Set(["android", "ios"]);
6192
93const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url);
62const appRoot = Effect.service(Path.Path).pipe(94const appRoot = Effect.service(Path.Path).pipe(
63 Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))),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 ),
64);104);
65105
66const commandOutputOptions = {106const commandOutputOptions = {
...@@ -77,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>...@@ -77,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>
77 `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`,117 `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`,
78 );118 );
79119
80const runCommand = Effect.fn("runCommand")(function* (120export const runCommand = Effect.fn("runCommand")(function* (
81 command: string,121 command: string,
82 args: ReadonlyArray<string>,122 args: ReadonlyArray<string>,
83 cwd: string,123 cwd: string,
...@@ -85,42 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* (...@@ -85,42 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* (
85 yield* Console.log(`$ ${[command, ...args].join(" ")}`);125 yield* Console.log(`$ ${[command, ...args].join(" ")}`);
86 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;126 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
87 const spawnCommand = yield* resolveSpawnCommand(command, args);127 const spawnCommand = yield* resolveSpawnCommand(command, args);
88 const child = yield* spawner.spawn(128 const processContext = {
89 ChildProcess.make(spawnCommand.command, spawnCommand.args, {129 command,
90 cwd,130 argumentCount: spawnCommand.args.length,
91 ...commandOutputOptions,131 cwd,
92 shell: spawnCommand.shell,132 shell: spawnCommand.shell,
93 }),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 ),
94 );163 );
95 const exitCode = Number(yield* child.exitCode);
96164
97 if (exitCode !== 0) {165 if (exitCode !== 0) {
98 return yield* new NativeStaticCheckCommandError({166 return yield* new NativeStaticCheckCommandError({
99 command,167 ...processContext,
100 args,
101 cwd,
102 exitCode,168 exitCode,
103 });169 });
104 }170 }
105});171});
106172
107function collectSources(173export function collectSources(
108 directory: string,174 directory: string,
109 root: string,175 root: string,
110): Effect.Effect<176): Effect.Effect<
111 ReadonlyArray<string>,177 ReadonlyArray<string>,
112 PlatformError.PlatformError,178 NativeStaticCheckSourceDiscoveryError,
113 FileSystem.FileSystem | Path.Path179 FileSystem.FileSystem | Path.Path
114> {180> {
115 return Effect.gen(function* () {181 return Effect.gen(function* () {
116 const fs = yield* FileSystem.FileSystem;182 const fs = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;183 const path = yield* Path.Path;
118 const entries = yield* fs.readDirectory(directory);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 );
119 const sources: Array<string> = [];194 const sources: Array<string> = [];
120195
121 for (const entry of entries) {196 for (const entry of entries) {
122 const entryPath = path.join(directory, entry);197 const entryPath = path.join(directory, entry);
123 const stat = yield* fs.stat(entryPath);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 );
124208
125 if (stat.type === "Directory") {209 if (stat.type === "Directory") {
126 const isGeneratedNativeProjectDirectory =210 const isGeneratedNativeProjectDirectory =