| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "alchemy-test"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as ts from "typescript-api/unstable/ast"; |
| 7 | import { API } from "typescript-api/unstable/async"; |
| 8 | |
| 9 | const forbiddenPatterns = [ |
| 10 | { |
| 11 | name: "raw filesystem/path/os imports", |
| 12 | pattern: |
| 13 | /\bfrom\s+["'](?:node:fs|node:fs\/promises|node:path|node:os|pathe)["']/, |
| 14 | }, |
| 15 | { |
| 16 | name: "async/await", |
| 17 | pattern: /(?:^|[^\w.])(?:async|await)\b/, |
| 18 | }, |
| 19 | { |
| 20 | name: "Effect.promise", |
| 21 | pattern: /\bEffect\.promise\b/, |
| 22 | }, |
| 23 | { |
| 24 | name: "raw Promise constructor", |
| 25 | pattern: /\bnew\s+Promise\b/, |
| 26 | }, |
| 27 | { |
| 28 | name: "raw fetch", |
| 29 | pattern: /\bfetch\s*\(/, |
| 30 | }, |
| 31 | { |
| 32 | name: "Effect.orDie", |
| 33 | pattern: /\bEffect\.orDie\b/, |
| 34 | }, |
| 35 | { |
| 36 | name: "legacy create/update lifecycle handlers", |
| 37 | pattern: /\b(?:create|update)\s*:\s*Effect\.fn\b/, |
| 38 | }, |
| 39 | { |
| 40 | name: "explicit output undefined create/update branch", |
| 41 | pattern: |
| 42 | /\b(?:output\s*(?:===|!==)\s*undefined|undefined\s*(?:===|!==)\s*output)\b/, |
| 43 | }, |
| 44 | { |
| 45 | name: "bare process.cwd()", |
| 46 | pattern: /\bprocess\.cwd\(\)/, |
| 47 | }, |
| 48 | { |
| 49 | name: "explicit any", |
| 50 | pattern: /\bany\b/, |
| 51 | }, |
| 52 | { |
| 53 | name: "double unknown cast", |
| 54 | pattern: /\bas\s+unknown\s+as\b/, |
| 55 | }, |
| 56 | ] as const; |
| 57 | |
| 58 | const documentedResources = [ |
| 59 | "Branch", |
| 60 | "Bucket", |
| 61 | "BucketAccessKey", |
| 62 | "Compute", |
| 63 | "App", |
| 64 | "Deployment", |
| 65 | "Connection", |
| 66 | "CustomDomain", |
| 67 | "Database", |
| 68 | "EnvironmentVariable", |
| 69 | "Project", |
| 70 | "SourceRepository", |
| 71 | ] as const; |
| 72 | |
| 73 | const resourceConfigInterfaces = { |
| 74 | Compute: ["ComputeBuild", "ComputeDev", "ComputeProps", "ComputeStaticBuild"], |
| 75 | Connection: ["ConnectionEnvOptions"], |
| 76 | Database: ["DatabaseDev"], |
| 77 | } as const; |
| 78 | |
| 79 | // These are interop boundaries, not lifecycle implementation shortcuts. Keep |
| 80 | // exceptions scoped to both a file and a rule so other conventions still apply. |
| 81 | const boundaryPatterns: Record<string, ReadonlyArray<string>> = { |
| 82 | "Internal/ArchivePlatform.ts": [ |
| 83 | "raw filesystem/path/os imports", |
| 84 | "async/await", |
| 85 | ], |
| 86 | "Internal/ArtifactFile.ts": ["raw filesystem/path/os imports", "async/await"], |
| 87 | // Resolve user paths against the cwd at invocation time, not module import. |
| 88 | "ComputeArchive.ts": ["bare process.cwd()"], |
| 89 | "ORM/Contract.ts": ["bare process.cwd()"], |
| 90 | "ORM/Migrate.ts": ["bare process.cwd()"], |
| 91 | // Prisma's native authoring function is retyped to preserve its generic API. |
| 92 | "ORM/ContractBuilder.ts": ["double unknown cast"], |
| 93 | // The fluent runtime proxy erases intermediate native query-builder types. |
| 94 | "ORM/OrmClient.ts": ["explicit any"], |
| 95 | // Native Promise transactions and close/rollback finalizers cross into Effect. |
| 96 | "ORM/Postgres.ts": ["async/await", "Effect.promise"], |
| 97 | }; |
| 98 | |
| 99 | const stripStringsAndComments = (source: string) => |
| 100 | source |
| 101 | .replaceAll(/`(?:\\.|[^`])*`/gs, "``") |
| 102 | .replaceAll(/"(?:\\.|[^"])*"/g, '""') |
| 103 | .replaceAll(/'(?:\\.|[^'])*'/g, "''") |
| 104 | .replaceAll(/\/\*[\s\S]*?\*\//g, "") |
| 105 | .replaceAll(/\/\/.*$/gm, ""); |
| 106 | |
| 107 | const prismaSourceProject = Effect.gen(function* () { |
| 108 | const path = yield* Path.Path; |
| 109 | const sourceRoot = path.resolve(import.meta.dirname, "../../src/Prisma"); |
| 110 | const configPath = path.join(sourceRoot, "tsconfig.source-conventions.json"); |
| 111 | const config = JSON.stringify({ |
| 112 | files: documentedResources.map((resource) => |
| 113 | path.join(sourceRoot, `${resource}.ts`), |
| 114 | ), |
| 115 | compilerOptions: { noResolve: true, noLib: true, types: [] }, |
| 116 | }); |
| 117 | const api = yield* Effect.acquireRelease( |
| 118 | Effect.sync( |
| 119 | () => |
| 120 | new API({ |
| 121 | fs: { |
| 122 | readFile: (file) => (file === configPath ? config : undefined), |
| 123 | }, |
| 124 | }), |
| 125 | ), |
| 126 | (api) => Effect.promise(() => api.close()), |
| 127 | ); |
| 128 | const snapshot = yield* Effect.tryPromise(() => |
| 129 | api.updateSnapshot({ openProjects: [configPath] }), |
| 130 | ); |
| 131 | const project = snapshot.getProject(configPath); |
| 132 | if (!project) throw new Error(`Missing project ${configPath}`); |
| 133 | return project.program; |
| 134 | }); |
| 135 | |
| 136 | describe( |
| 137 | "Prisma source conventions", |
| 138 | { tags: ["unit", "provider:prisma", "local"] }, |
| 139 | () => { |
| 140 | it.effect( |
| 141 | "keeps provider source in Effect-style lifecycle conventions", |
| 142 | () => |
| 143 | Effect.gen(function* () { |
| 144 | const fs = yield* FileSystem.FileSystem; |
| 145 | const path = yield* Path.Path; |
| 146 | const sourceRoot = path.resolve( |
| 147 | import.meta.dirname, |
| 148 | "../../src/Prisma", |
| 149 | ); |
| 150 | const files = (yield* fs.readDirectory(sourceRoot, { |
| 151 | recursive: true, |
| 152 | })) |
| 153 | .filter((file) => file.endsWith(".ts")) |
| 154 | .sort(); |
| 155 | |
| 156 | const violations: string[] = []; |
| 157 | for (const file of files) { |
| 158 | const fullPath = path.join(sourceRoot, file); |
| 159 | const source = yield* fs.readFileString(fullPath); |
| 160 | for (const { name, pattern } of forbiddenPatterns) { |
| 161 | if (boundaryPatterns[file]?.includes(name)) continue; |
| 162 | const scannedSource = |
| 163 | name === "raw filesystem/path/os imports" |
| 164 | ? source |
| 165 | : stripStringsAndComments(source); |
| 166 | const match = pattern.exec(scannedSource); |
| 167 | if (match) { |
| 168 | violations.push(`${file}: ${name}: ${match[0]}`); |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | expect(violations).toEqual([]); |
| 174 | }).pipe(Effect.provide(NodeServices.layer)), |
| 175 | ); |
| 176 | |
| 177 | it.effect("keeps Prisma resources ready for generated API docs", () => |
| 178 | Effect.gen(function* () { |
| 179 | const fs = yield* FileSystem.FileSystem; |
| 180 | const path = yield* Path.Path; |
| 181 | const sourceRoot = path.resolve( |
| 182 | import.meta.dirname, |
| 183 | "../../src/Prisma", |
| 184 | ); |
| 185 | |
| 186 | for (const resource of documentedResources) { |
| 187 | const source = yield* fs.readFileString( |
| 188 | path.join(sourceRoot, `${resource}.ts`), |
| 189 | ); |
| 190 | expect(source).toContain(`export interface ${resource}Props`); |
| 191 | const constructorPattern = |
| 192 | resource === "Compute" |
| 193 | ? `export const ${resource}: Platform<` |
| 194 | : `export const ${resource} = Resource<`; |
| 195 | expect(source).toContain(constructorPattern); |
| 196 | |
| 197 | const resourceDoc = source.match( |
| 198 | new RegExp( |
| 199 | resource === "Compute" |
| 200 | ? `/\\*\\*[\\s\\S]*?\\*/\\s*export const ${resource}: Platform<` |
| 201 | : `/\\*\\*[\\s\\S]*?\\*/\\s*export const ${resource} = Resource<`, |
| 202 | ), |
| 203 | )?.[0]; |
| 204 | if (resourceDoc === undefined) { |
| 205 | throw new Error(`${resource} is missing resource JSDoc`); |
| 206 | } |
| 207 | // `docs:fix-jsdoc` rewrites `@section` / `@example` into markdown |
| 208 | // headings (`###` / `**Example:**`). Source of truth is the |
| 209 | // rewritten form — see scripts/fix-api-jsdocs.ts. |
| 210 | expect(resourceDoc).toContain("### "); |
| 211 | expect(resourceDoc).toContain("**Example:**"); |
| 212 | } |
| 213 | }).pipe(Effect.provide(NodeServices.layer)), |
| 214 | ); |
| 215 | |
| 216 | it.effect("documents public Prisma resource props and attributes", () => |
| 217 | Effect.gen(function* () { |
| 218 | const path = yield* Path.Path; |
| 219 | const sourceRoot = path.resolve( |
| 220 | import.meta.dirname, |
| 221 | "../../src/Prisma", |
| 222 | ); |
| 223 | const program = yield* prismaSourceProject; |
| 224 | const missingDocs: string[] = []; |
| 225 | |
| 226 | for (const resource of documentedResources) { |
| 227 | const fileName = `${resource}.ts`; |
| 228 | const sourceFile = yield* Effect.tryPromise(() => |
| 229 | program.getSourceFile(path.join(sourceRoot, fileName)), |
| 230 | ); |
| 231 | if (!sourceFile) throw new Error(`Missing source file ${fileName}`); |
| 232 | const configInterfaces = [ |
| 233 | `${resource}Props`, |
| 234 | ...(resourceConfigInterfaces[ |
| 235 | resource as keyof typeof resourceConfigInterfaces |
| 236 | ] ?? []), |
| 237 | ]; |
| 238 | |
| 239 | for (const interfaceName of configInterfaces) { |
| 240 | const declaration = sourceFile.statements |
| 241 | .filter(ts.isInterfaceDeclaration) |
| 242 | .find((node) => node.name.text === interfaceName); |
| 243 | if (declaration === undefined) continue; |
| 244 | for (const property of declaration.members.filter( |
| 245 | ts.isPropertySignatureDeclaration, |
| 246 | )) { |
| 247 | if (!property.jsDoc?.length) { |
| 248 | missingDocs.push( |
| 249 | `${fileName}:${interfaceName}.${property.name.getText()}`, |
| 250 | ); |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | const resourceDeclaration = sourceFile.statements |
| 256 | .filter(ts.isInterfaceDeclaration) |
| 257 | .find((node) => node.name.text === resource); |
| 258 | const attrs = resourceDeclaration?.heritageClauses?.find( |
| 259 | (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword, |
| 260 | )?.types[0]?.typeArguments?.[2]; |
| 261 | const attributes = |
| 262 | attrs && ts.isTypeLiteralNode(attrs) ? attrs : undefined; |
| 263 | if (attributes === undefined) continue; |
| 264 | for (const property of attributes.members.filter( |
| 265 | ts.isPropertySignatureDeclaration, |
| 266 | )) { |
| 267 | if (!property.jsDoc?.length) { |
| 268 | missingDocs.push( |
| 269 | `${fileName}:${resource}.Attributes.${property.name.getText()}`, |
| 270 | ); |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | expect(missingDocs).toEqual([]); |
| 276 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 277 | ); |
| 278 | |
| 279 | it.effect("keeps raw artifact bytes out of persisted resource props", () => |
| 280 | Effect.gen(function* () { |
| 281 | const path = yield* Path.Path; |
| 282 | const sourceRoot = path.resolve( |
| 283 | import.meta.dirname, |
| 284 | "../../src/Prisma", |
| 285 | ); |
| 286 | const program = yield* prismaSourceProject; |
| 287 | |
| 288 | for (const resource of ["Compute", "Deployment"] as const) { |
| 289 | const sourceFile = yield* Effect.tryPromise(() => |
| 290 | program.getSourceFile(path.join(sourceRoot, `${resource}.ts`)), |
| 291 | ); |
| 292 | if (!sourceFile) |
| 293 | throw new Error(`Missing source file ${resource}.ts`); |
| 294 | const props = sourceFile.statements |
| 295 | .filter(ts.isInterfaceDeclaration) |
| 296 | .find((node) => node.name.text === `${resource}Props`); |
| 297 | const properties = props?.members |
| 298 | .filter(ts.isPropertySignatureDeclaration) |
| 299 | .map((node) => node.name.getText()); |
| 300 | expect(properties).not.toContain("artifact"); |
| 301 | expect(properties).toContain("artifactPath"); |
| 302 | } |
| 303 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 304 | ); |
| 305 | |
| 306 | it.effect("models deployment environment values as redaction markers", () => |
| 307 | Effect.gen(function* () { |
| 308 | const fs = yield* FileSystem.FileSystem; |
| 309 | const path = yield* Path.Path; |
| 310 | const source = yield* fs.readFileString( |
| 311 | path.resolve(import.meta.dirname, "../../src/Prisma/Types.ts"), |
| 312 | ); |
| 313 | |
| 314 | expect(source).toContain( |
| 315 | 'export type RedactedDeploymentEnvironmentValue = "[redacted]"', |
| 316 | ); |
| 317 | expect(source).toContain( |
| 318 | "envVars?: Record<string, RedactedDeploymentEnvironmentValue>", |
| 319 | ); |
| 320 | }).pipe(Effect.provide(NodeServices.layer)), |
| 321 | ); |
| 322 | }, |
| 323 | ); |