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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as HostProcess from "@t3tools/shared/hostProcess";
13import { 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", () => {
6 const error = new NativeStaticCheckCommandError({
7 command: "swiftlint",
8 args: ["lint", "--strict"],
9 cwd: "/repo/apps/mobile",
10 exitCode: 2,
15const processHandle = (
16 exitCode: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
17) =>
18 ChildProcessSpawner.makeHandle({
19 pid: ChildProcessSpawner.ProcessId(1),
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,
1130 });
1231
13 assert.equal(error.command, "swiftlint");
14 assert.deepStrictEqual(error.args, ["lint", "--strict"]);
15 assert.equal(error.cwd, "/repo/apps/mobile");
16 assert.equal(error.exitCode, 2);
17 assert.equal(error.message, "Native static check command 'swiftlint' exited with code 2.");
32const provideSpawner = (spawn: ChildProcessSpawner.ChildProcessSpawner["Service"]["spawn"]) =>
33 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(spawn));
34
35const runSwiftLint = runCommand("swiftlint", ["lint", "--strict"], "/repo/apps/mobile").pipe(
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 });
1887});
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";
88import * as FileSystem from "effect/FileSystem";
99import * as Logger from "effect/Logger";
1010import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
1211import * as Schema from "effect/Schema";
1312import { Command } from "effect/unstable/cli";
1413import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
......@@ -18,12 +17,44 @@ interface NativeStaticTool {
1817 readonly installHint: string;
1918}
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
2151export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass<NativeStaticCheckCommandError>()(
2252 "NativeStaticCheckCommandError",
2353 {
2454 command: Schema.String,
25 args: Schema.Array(Schema.String),
55 argumentCount: NonNegativeInt,
2656 cwd: Schema.String,
57 shell: Schema.Boolean,
2758 exitCode: Schema.Int,
2859 },
2960) {
......@@ -59,8 +90,17 @@ const excludedDirectories = new Set([
5990]);
6091const generatedNativeProjectDirectories = new Set(["android", "ios"]);
6192
93const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url);
6294const 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 ),
64104);
65105
66106const commandOutputOptions = {
......@@ -77,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>
77117 `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`,
78118 );
79119
80const runCommand = Effect.fn("runCommand")(function* (
120export const runCommand = Effect.fn("runCommand")(function* (
81121 command: string,
82122 args: ReadonlyArray<string>,
83123 cwd: string,
......@@ -85,42 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* (
85125 yield* Console.log(`$ ${[command, ...args].join(" ")}`);
86126 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
87127 const spawnCommand = yield* resolveSpawnCommand(command, args);
88 const child = yield* spawner.spawn(
89 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
90 cwd,
91 ...commandOutputOptions,
92 shell: spawnCommand.shell,
93 }),
128 const processContext = {
129 command,
130 argumentCount: spawnCommand.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 ),
94163 );
95 const exitCode = Number(yield* child.exitCode);
96164
97165 if (exitCode !== 0) {
98166 return yield* new NativeStaticCheckCommandError({
99 command,
100 args,
101 cwd,
167 ...processContext,
102168 exitCode,
103169 });
104170 }
105171});
106172
107function collectSources(
173export function collectSources(
108174 directory: string,
109175 root: string,
110176): Effect.Effect<
111177 ReadonlyArray<string>,
112 PlatformError.PlatformError,
178 NativeStaticCheckSourceDiscoveryError,
113179 FileSystem.FileSystem | Path.Path
114180> {
115181 return Effect.gen(function* () {
116182 const fs = yield* FileSystem.FileSystem;
117183 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 );
119194 const sources: Array<string> = [];
120195
121196 for (const entry of entries) {
122197 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
125209 if (stat.type === "Directory") {
126210 const isGeneratedNativeProjectDirectory =