142 lines · 4.4 KB · JavaScript
RawDownload
import fs from "node:fs";import path from "node:path"; import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js"; const schemaClassExportCache = new Map(); const message =  "Use a structural Schema.Struct for HttpApiEndpoint payloads; construct Schema.Class values inside handlers after decoding."; const SCHEMA_CLASS_PATTERN =  /export\s+class\s+([A-Za-z_$][\w$]*)\s+extends\s+Schema\.(?:Class|TaggedClass|ErrorClass|TaggedErrorClass)\b/g; export default {  meta: {    type: "problem",    docs: {      description: "Disallow Schema.Class-backed schemas as HttpApiEndpoint payload schemas.",    },  },  create(context) {    const schemaClassNames = new Set();     const addImportedSchemaClasses = (node) => {      const specifier = node.source.value;      if (typeof specifier !== "string" || !specifier.startsWith(".")) return;       const exportedClassNames = readSchemaClassExports(context.filename, specifier);      if (exportedClassNames.size === 0) return;       for (const imported of node.specifiers ?? []) {        if (imported.type !== "ImportSpecifier") continue;        const importedName = getPropertyName(imported.imported);        const localName = imported.local?.name;        if (importedName && localName && exportedClassNames.has(importedName)) {          schemaClassNames.add(localName);        }      }    };     return {      ImportDeclaration: addImportedSchemaClasses,       ClassDeclaration(node) {        if (!node.id?.name || !isSchemaClassExtends(node.superClass)) return;        schemaClassNames.add(node.id.name);      },       CallExpression(node) {        if (!isHttpApiEndpointCall(node.callee)) return;         for (const arg of node.arguments ?? []) {          const payload = getObjectPropertyValue(arg, "payload");          if (!payload || !isSchemaClassPayload(payload, schemaClassNames)) continue;           context.report({            node: payload,            message,          });        }      },    };  },}; function isHttpApiEndpointCall(callee) {  const expression = unwrapExpression(callee);  if (expression?.type !== "MemberExpression") return false;  return isIdentifier(unwrapExpression(expression.object), "HttpApiEndpoint");} function getObjectPropertyValue(node, name) {  const expression = unwrapExpression(node);  if (expression?.type !== "ObjectExpression") return undefined;   for (const property of expression.properties ?? []) {    if (property.type === "SpreadElement") continue;    if (getPropertyName(property.key) === name) return unwrapExpression(property.value);  }  return undefined;} function isSchemaClassPayload(node, schemaClassNames) {  const expression = unwrapExpression(node);  if (isIdentifier(expression) && schemaClassNames.has(expression.name)) return true;  return isSchemaClassCall(expression);} function isSchemaClassExtends(node) {  const expression = unwrapExpression(node);  return isSchemaClassCall(expression);} function isSchemaClassCall(node) {  const expression = unwrapExpression(node);  if (expression?.type !== "CallExpression") return false;   let callee = unwrapExpression(expression.callee);  while (callee?.type === "CallExpression") {    callee = unwrapExpression(callee.callee);  }   if (callee?.type !== "MemberExpression") return false;  if (!isIdentifier(unwrapExpression(callee.object), "Schema")) return false;  const method = getPropertyName(callee.property);  return (    method === "Class" ||    method === "TaggedClass" ||    method === "ErrorClass" ||    method === "TaggedErrorClass"  );} function readSchemaClassExports(filename, specifier) {  const resolved = resolveImport(filename, specifier);  if (!resolved) return new Set();  if (schemaClassExportCache.has(resolved)) return schemaClassExportCache.get(resolved);   const source = fs.readFileSync(resolved, "utf8");  const names = new Set();  for (const match of source.matchAll(SCHEMA_CLASS_PATTERN)) {    names.add(match[1]);  }  schemaClassExportCache.set(resolved, names);  return names;} function resolveImport(filename, specifier) {  const base = path.resolve(path.dirname(filename), specifier);  const candidates = [    base,    `${base}.ts`,    `${base}.tsx`,    `${base}.js`,    path.join(base, "index.ts"),    path.join(base, "index.tsx"),    path.join(base, "index.js"),  ];  return candidates.find(    (candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile(),  );}