Skip to content
Source
Sign inSign up
Open History to see the last commit

lint-providers.ts

203 lines7.6 KB

1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Command } from "effect/cli";
4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as ts from "tsrs/unstable/ast";
9import { API, TypeFlags, type Type, type Project } from "tsrs/unstable/async";
10​
11/**
12 * Lints every `Providers.ts` under a workspace package's `src` (alchemy's
13 * `src/{Cloud}/Providers.ts` and each provider package's `src/Providers.ts`)
14 * and fails if any
15 * `providers()` factory ends up with `unknown` or `any` in its Layer requirements
16 * (the 3rd `Layer<ROut, E, RIn>` type argument). An `unknown`/`any` RIn means some leaf
17 * provider layer leaked an unsatisfied/undeclared requirement, which silently
18 * poisons `StackServices` inference across every consumer.
19 *
20 * For each offending file it reports the specific leaf provider-layer factory
21 * call(s) whose requirements include `unknown`.
22 */
23​
24/** Workspace packages with a `Providers.ts` anywhere under `src`. */
25const providerPackages = Effect.fn(function* (packagesRoot: string) {
26 const fs = yield* FileSystem.FileSystem;
27 const path = yield* Path.Path;
28 const found: Array<string> = [];
29 for (const name of yield* fs.readDirectory(packagesRoot)) {
30 const src = path.join(packagesRoot, name, "src");
31 if (!(yield* fs.exists(src))) continue;
32 const files = yield* fs.readDirectory(src, { recursive: true });
33 if (files.some((file) => path.basename(file) === "Providers.ts")) {
34 found.push(path.join(packagesRoot, name));
35 }
36 }
37 return found.sort((a, b) => a.localeCompare(b));
38});
39​
40/**
41 * Lints one project's `Providers.ts` files, calling `log` per line of output.
42 * Resolves to `false` if any `providers()` factory leaks `unknown`/`any`.
43 */
44async function lintProviders(
45 project: Project,
46 srcRoot: string,
47 relative: (file: string) => string,
48 log: (message: string) => void,
49): Promise<boolean> {
50 const { checker, program } = project;
51 const providerPaths = (await program.getSourceFileNames())
52 .filter((file) => file.startsWith(`${srcRoot}/`) && file.endsWith("/Providers.ts"))
53 .sort((a, b) => a.localeCompare(b));
54​
55 // A Layer's requirements are its 3rd type argument: Layer<ROut, E, RIn>.
56 async function layerRequirements(type: Type): Promise<Type | undefined> {
57 const args = type.isTypeReference() ? await checker.getTypeArguments(type) : [];
58 if (args.length === 3) return args[2];
59 return undefined;
60 }
61​
62 // `any` absorbs every other union member, so it must be checked alongside
63 // `unknown` or a leaked `any` silently passes.
64 async function containsUnknown(type: Type): Promise<boolean> {
65 if (type.flags & (TypeFlags.Unknown | TypeFlags.Any)) return true;
66 if (type.isUnionType()) {
67 for (const member of await type.getTypes()) {
68 if (await containsUnknown(member)) return true;
69 }
70 }
71 return false;
72 }
73​
74 let hadError = false;
75​
76 for (const file of providerPaths) {
77 const sourceFile = await program.getSourceFile(file);
78 if (!sourceFile) throw new Error(`Missing source file ${file}`);
79 const rel = relative(sourceFile.fileName);
80 const providersVar = sourceFile.statements
81 .filter(ts.isVariableStatement)
82 .flatMap((statement) => statement.declarationList.declarations)
83 .find(
84 (declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === "providers",
85 );
86 if (!providersVar) continue;
87​
88 // Overall requirements of the `providers()` factory return value.
89 const factoryType = await checker.getTypeAtLocation(providersVar);
90 const signature = (await factoryType.getCallSignatures())[0];
91 const returnType = signature ? await checker.getReturnTypeOfSignature(signature) : undefined;
92 const overallReq = returnType ? await layerRequirements(returnType) : undefined;
93​
94 if (!overallReq || !(await containsUnknown(overallReq))) {
95 log(`✓ ${rel}`);
96 continue;
97 }
98​
99 hadError = true;
100​
101 // Localize the leak structurally: every Layer-typed expression whose RIn
102 // is `unknown`/`any`, keeping only the innermost ones (composites such as
103 // `Layer.mergeAll(...)` or `.pipe(...)` merely inherit it from a child).
104 const candidates: ts.Node[] = [];
105 const visit = (node: ts.Node) => {
106 if (
107 ts.isCallExpression(node) ||
108 ts.isPropertyAccessExpression(node) ||
109 (ts.isIdentifier(node) &&
110 !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node))
111 ) {
112 candidates.push(node);
113 }
114 node.forEachChild(visit);
115 };
116 providersVar.forEachChild(visit);
117 const leaking: { node: ts.Node; req: Type }[] = [];
118 for (const node of candidates) {
119 const type = await checker.getTypeAtLocation(node);
120 const req = await layerRequirements(type);
121 if (
122 req &&
123 (await containsUnknown(req)) &&
124 (await checker.typeToString(type)).startsWith("Layer<")
125 ) {
126 leaking.push({ node, req });
127 }
128 }
129 const offenders: { text: string; req: string }[] = [];
130 for (const { node, req } of leaking) {
131 const hasLeakingChild = leaking.some(
132 (other) => other.node !== node && other.node.pos >= node.pos && other.node.end <= node.end,
133 );
134 if (hasLeakingChild) continue;
135 offenders.push({
136 text: node.getText(sourceFile).replace(/\s+/g, " ").slice(0, 120),
137 req: await checker.typeToString(req),
138 });
139 }
140​
141 log(
142 `✗ ${rel} -> providers() RIn includes \`unknown\`/\`any\` (${await checker.typeToString(overallReq)})`,
143 );
144 if (offenders.length === 0) {
145 log(" (could not localize a leaf culprit — inspect the composite layers)");
146 }
147 for (const o of offenders) {
148 log(` ✗ ${o.text} -> RIn = ${o.req}`);
149 }
150 }
151​
152 return !hadError;
154​
155const command = Command.make(
156 "lint-providers",
157 {},
158 Effect.fn(function* () {
159 const path = yield* Path.Path;
160 const cwd = yield* Effect.sync(() => process.cwd());
161 const packages = yield* providerPackages(path.resolve(import.meta.dirname, "../packages"));
162 const api = yield* Effect.acquireRelease(
163 Effect.sync(() => new API()),
164 (api) => Effect.promise(() => api.close()),
165 );
166 const tsConfigs = packages.map((dir) => path.join(dir, "tsconfig.json"));
167 const snapshot = yield* Effect.promise(() => api.createSnapshot({ openProjects: tsConfigs }));
168 let ok = true;
169 for (const dir of packages) {
170 const tsConfig = path.join(dir, "tsconfig.json");
171 const project = snapshot.getConfiguredProject(tsConfig);
172 if (!project) return yield* Effect.die(new Error(`Failed to open ${tsConfig}`));
173 const lines: Array<string> = [];
174 const passed = yield* Effect.promise(() =>
175 lintProviders(
176 project,
177 path.join(dir, "src"),
178 (file) => path.relative(cwd, file),
179 (line) => lines.push(line),
180 ),
181 );
182 for (const line of lines) yield* Console.log(line);
183 ok = passed && ok;
184 }
185 if (!ok) {
186 return yield* Effect.fail(
187 new Error(
188 "Provider lint failed: one or more `providers()` factories have `unknown`/`any` requirements.",
189 ),
190 );
191 }
192 yield* Console.log("\nAll provider factories have fully-resolved requirements.");
193 }, Effect.scoped),
194).pipe(
195 Command.withDescription(
196 "Fail if any provider package's `providers()` factory leaks `unknown`/`any` requirements",
197 ),
198);
199​
200Command.run(command, { version: "0.0.0" }).pipe(
201 Effect.provide(NodeServices.layer),
202 NodeRuntime.runMain,
203);