300 lines · 8.3 KB · TypeScript
RawDownload
import fs from "node:fs";import path from "node:path";import ts from "typescript"; type FindingLevel = "error" | "warning"; interface Finding {  level: FindingLevel;  rule: string;  path: string;  detail: string;} interface AuditStats {  directories: number;  files: number;} const SRC_DIR = path.resolve(process.cwd(), "src");const MAX_FILES_PER_DIR = Number.parseInt(process.env["MAX_FILES_PER_DIR"] ?? "20", 10);const MAX_SUBDIRS_PER_DIR = Number.parseInt(process.env["MAX_SUBDIRS_PER_DIR"] ?? "8", 10);const STRUCTURE_COUNT_EXCLUDED_DIRS = new Set(["tests"]); const findings: Finding[] = [];const stats: AuditStats = {  directories: 0,  files: 0,};const modulesRoot = path.join(SRC_DIR, "modules");const runtimeBoundaryFiles = new Set(["http/bounded-body.ts", "http/effect-handler.ts", "main.ts"]);let managedRuntimeCount = 0; const kebabCase = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/; function addSourceFinding(rule: string, filePath: string, node: ts.Node, detail: string): void {  const sourceFile = node.getSourceFile();  const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));  findings.push({    level: "error",    rule,    path: filePath,    detail: `${line + 1}:${character + 1} ${detail}`,  });} function identifierText(node: ts.Node): string | null {  return ts.isIdentifier(node) ? node.text : null;} function isEffectCompositionCatch(node: ts.CallExpression): boolean {  return (    ts.isPropertyAccessExpression(node.expression) &&    node.expression.name.text === "catch" &&    ["Effect", "Stream"].includes(identifierText(node.expression.expression) ?? "")  );} function isInsideEffectTryPromise(node: ts.Node): boolean {  let parent = node.parent;  while (parent) {    if (      ts.isCallExpression(parent) &&      ts.isPropertyAccessExpression(parent.expression) &&      identifierText(parent.expression.expression) === "Effect" &&      parent.expression.name.text === "tryPromise"    ) {      return true;    }    parent = parent.parent;  }  return false;} function scanEffectStandards(filePath: string): void {  if (!filePath.endsWith(".ts") || filePath.endsWith(".d.ts")) return;  const source = fs.readFileSync(filePath, "utf8");  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);  const relativePath = path.relative(SRC_DIR, filePath);  const isRuntimeBoundary = runtimeBoundaryFiles.has(relativePath);   const visit = (node: ts.Node): void => {    if (ts.canHaveModifiers(node)) {      const modifiers = ts.getModifiers(node);      if (        modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) &&        !isInsideEffectTryPromise(node)      ) {        addSourceFinding(          "effect-async-boundary",          filePath,          node,          "Use Effect for controller async work",        );      }    }     if (      !isRuntimeBoundary &&      ts.isTypeReferenceNode(node) &&      ["Promise", "PromiseLike"].includes(identifierText(node.typeName) ?? "")    ) {      addSourceFinding(        "effect-promise-type",        filePath,        node,        "Promise types are restricted to runtime adapters",      );    }     if (      !isRuntimeBoundary &&      ts.isNewExpression(node) &&      identifierText(node.expression) === "Promise"    ) {      addSourceFinding(        "effect-promise-constructor",        filePath,        node,        "Use Effect.async or Effect.callback",      );    }     if (ts.isIdentifier(node) && ["AsyncLock", "AsyncQueue"].includes(node.text)) {      addSourceFinding(        "effect-legacy-concurrency",        filePath,        node,        "Use Effect concurrency primitives",      );    }     if (ts.isCallExpression(node)) {      if (        ts.isPropertyAccessExpression(node.expression) &&        identifierText(node.expression.expression) === "ManagedRuntime" &&        node.expression.name.text === "make"      ) {        managedRuntimeCount += 1;      }       if (        !isRuntimeBoundary &&        ts.isPropertyAccessExpression(node.expression) &&        ["runPromise", "runPromiseExit", "runSync", "runFork"].includes(          node.expression.name.text,        ) &&        (identifierText(node.expression.expression) === "Effect" ||          /runtime/i.test(node.expression.expression.getText(sourceFile)))      ) {        addSourceFinding(          "effect-runner-boundary",          filePath,          node,          "Effect runners are restricted to runtime adapters",        );      }       if (        !isRuntimeBoundary &&        ts.isPropertyAccessExpression(node.expression) &&        ["then", "finally"].includes(node.expression.name.text)      ) {        addSourceFinding("effect-promise-chain", filePath, node, "Use Effect composition");      }       if (        !isRuntimeBoundary &&        ts.isPropertyAccessExpression(node.expression) &&        node.expression.name.text === "catch" &&        !isEffectCompositionCatch(node)      ) {        addSourceFinding(          "effect-promise-catch",          filePath,          node,          "Use Effect.catch or Effect.catchTag",        );      }       if (        !isRuntimeBoundary &&        ts.isPropertyAccessExpression(node.expression) &&        identifierText(node.expression.expression) === "Promise"      ) {        addSourceFinding(          "effect-promise-static",          filePath,          node,          "Use Effect concurrency and coordination APIs",        );      }    }     ts.forEachChild(node, visit);  };   visit(sourceFile);} function scanDirectory(dir: string): void {  const entries = fs.readdirSync(dir, { withFileTypes: true });  const directFiles = entries.filter((entry) => entry.isFile());  const directDirectories = entries.filter(    (entry) =>      entry.isDirectory() &&      !entry.name.startsWith(".") &&      !STRUCTURE_COUNT_EXCLUDED_DIRS.has(entry.name),  );   stats.directories += 1;  stats.files += directFiles.length;   if (directFiles.length > MAX_FILES_PER_DIR) {    findings.push({      level: "error",      rule: "directory-file-limit",      path: dir,      detail: `${directFiles.length} files (limit ${MAX_FILES_PER_DIR})`,    });  }   if (dir !== modulesRoot && directDirectories.length > MAX_SUBDIRS_PER_DIR) {    findings.push({      level: "error",      rule: "directory-subdir-limit",      path: dir,      detail: `${directDirectories.length} subdirectories (limit ${MAX_SUBDIRS_PER_DIR})`,    });  }   for (const entry of entries) {    const fullPath = path.join(dir, entry.name);    if (entry.name.startsWith(".")) {      continue;    }     if (entry.isDirectory() && !kebabCase.test(entry.name)) {      findings.push({        level: "warning",        rule: "kebab-case",        path: fullPath,        detail: `Name "${entry.name}" is not kebab-case`,      });    }     if (entry.isDirectory()) {      scanDirectory(fullPath);    } else if (entry.isFile()) {      scanEffectStandards(fullPath);    }  }} function printSummary(): void {  const errors = findings.filter((f) => f.level === "error");  const warnings = findings.filter((f) => f.level === "warning");   console.log("=== Controller Standards Audit ===");  console.log(`Directories scanned: ${stats.directories}`);  console.log(`Direct file entries scanned: ${stats.files}`);  console.log(`Errors: ${errors.length}`);  console.log(`Warnings: ${warnings.length}`);  console.log("");   const sortedFindings = findings.sort((a, b) => {    if (a.level !== b.level) {      return a.level === "error" ? -1 : 1;    }    return a.path.localeCompare(b.path);  });   for (const finding of sortedFindings) {    const emoji = finding.level === "error" ? "[ERR]" : "[WARN]";    console.log(`${emoji} ${finding.rule} | ${finding.path}`);    console.log(`      ${finding.detail}`);  }} function run(): number {  if (!fs.existsSync(SRC_DIR)) {    console.error("ERROR: src directory not found");    return 1;  }   scanDirectory(SRC_DIR);  if (managedRuntimeCount !== 1) {    findings.push({      level: "error",      rule: "effect-single-runtime",      path: SRC_DIR,      detail: `${managedRuntimeCount} ManagedRuntime.make calls (expected exactly 1)`,    });  }  printSummary();   const hasErrors = findings.some((finding) => finding.level === "error");  return hasErrors ? 1 : 0;} process.exit(run());