/** * Effect-native law enforcement for the TrustGraph TS port. * * Encodes the adapted beep-effect effect-first/schema-first laws as regex and * AST sweeps over packages/*\/src and scripts/, with a ratcheting baseline: * every violation must be covered by an exact-count baseline entry or a * reasoned permanent exemption in scripts/effect-laws.allowlist.json. Counts * that drop force a baseline update (ratchet down); counts that grow fail. * * Usage: * bun scripts/check-effect-laws.ts # verify, exit 1 on drift * bun scripts/check-effect-laws.ts --write-baseline # regenerate baseline section */ import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"; import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import { Effect } from "effect"; import * as A from "effect/Array"; import * as Console from "effect/Console"; import * as FileSystem from "effect/FileSystem"; import * as O from "effect/Option"; import * as S from "effect/Schema"; import * as Str from "effect/String"; import ts from "typescript"; const ALLOWLIST_PATH = "scripts/effect-laws.allowlist.json"; const SELF_PATH = "scripts/check-effect-laws.ts"; class LawCheckFailed extends S.TaggedErrorClass()( "LawCheckFailed", { message: S.String }, ) {} class BaselineEntry extends S.Class("BaselineEntry")({ rule: S.String, path: S.String, count: S.Number, }) {} class Exemption extends S.Class("Exemption")({ rule: S.String, path: S.String, reason: S.String, }) {} class Allowlist extends S.Class("Allowlist")({ exemptions: S.Array(Exemption), baseline: S.Array(BaselineEntry), }) {} const AllowlistJson = S.fromJsonString(Allowlist); const decodeAllowlist = S.decodeUnknownEffect(AllowlistJson); const encodeAllowlist = S.encodeUnknownEffect(AllowlistJson); type RuleScope = "prod" | "all"; interface RegexRule { readonly id: string; readonly description: string; readonly scope: RuleScope; readonly pattern: RegExp; } interface Violation { readonly rule: string; readonly path: string; readonly line: number; readonly excerpt: string; } const regexRules: ReadonlyArray = [ { id: "no-native-json", description: "Use Schema JSON codecs (S.fromJsonString / S.UnknownFromJsonString), not native JSON", scope: "all", pattern: /JSON\.(parse|stringify)\(/, }, { id: "no-process-env", description: "Read configuration through Config / ConfigProvider, not process.env", scope: "all", pattern: /process\.env/, }, { id: "no-error-throw", description: "Fail with tagged errors on the Effect channel, not thrown native Error", scope: "all", pattern: /\bthrow\s+new\s|\bnew Error\(/, }, { id: "no-native-switch", description: "Branch with effect/Match or schema tagged-union matchers, not native switch", scope: "all", pattern: /\bswitch\s*\(/, }, { id: "no-native-sort", description: "Sort with A.sort and an explicit Order, not Array.prototype.sort", scope: "all", pattern: /\.sort\(\s*[)(]/, }, { id: "no-effect-run", description: "Run effects only at process/test boundaries (runMain); libraries return Effect", scope: "all", pattern: /Effect\.run(Sync|Promise|Fork)\b/, }, { id: "no-schema-suffix", description: "Schema constants carry the domain name, never a Schema suffix", scope: "all", pattern: /export const \w+Schema\b/, }, { id: "no-node-fs-path", description: "Use effect/FileSystem and effect/Path services, not node:fs / node:path", scope: "all", pattern: /from\s+"node:(fs|path)"/, }, { id: "no-native-fetch", description: "Use HttpClient from effect/unstable/http with a platform layer, not fetch", scope: "all", pattern: /\bfetch\s*\(/, }, { id: "no-ts-escape", description: "No any/test-escape hatches in source", scope: "all", pattern: /@ts-ignore|@ts-expect-error|\bas any\b/, }, { id: "no-native-timers", description: "Model time with Effect.sleep / Schedule / Duration, not setTimeout/setInterval", scope: "all", pattern: /\bset(Timeout|Interval)\(/, }, ]; const SCHEMA_FIRST_RULE = "schema-first-data"; const sourceFilePattern = /\.tsx?$/; const isExcludedDir = (segment: string): boolean => segment === "dist" || segment === "node_modules" || segment === ".turbo" || segment === "__tests__"; const isTestFile = (path: string): boolean => path.includes("/__tests__/") || /\.(test|spec)\.tsx?$/.test(path); const isProdSource = (path: string): boolean => path.startsWith("packages/") && path.includes("/src/") && !isTestFile(path); const isScriptSource = (path: string): boolean => path.startsWith("scripts/"); const collectSourceFiles = Effect.fn("LawCheck.collectSourceFiles")(function* ( fs: FileSystem.FileSystem, ) { const entries = yield* fs.readDirectory(".", { recursive: true }); return A.filter( entries, (path) => sourceFilePattern.test(path) && !path.endsWith(".d.ts") && path !== SELF_PATH && !A.some(path.split("/"), isExcludedDir) && !isTestFile(path) && (isProdSource(path) || isScriptSource(path)), ); }); const scanRegexRules = (path: string, text: string): ReadonlyArray => { const prod = isProdSource(path); const lines = text.split("\n"); return regexRules .filter((rule) => rule.scope === "all" || prod) .flatMap((rule) => lines.flatMap((lineText, index) => rule.pattern.test(lineText) ? [{ rule: rule.id, path, line: index + 1, excerpt: Str.trim(lineText).slice(0, 120), }] : [], ), ); }; /** * Law 38/39 split: an exported interface or type literal whose members are all * non-function property signatures is a pure-data model and must be a Schema. * Members with call/method/construct/index signatures or function-typed * properties mark a service contract, which may stay an interface. */ const scanSchemaFirst = (path: string, text: string): ReadonlyArray => { if (!isProdSource(path) || path.endsWith(".tsx")) return []; const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const violations: Violation[] = []; const isExported = (node: ts.HasModifiers): boolean => ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false; // A same-name const (schema value + type merge, e.g. recursive S.suspend // schemas) means the schema already exists; the companion type is required. const constNames = new Set(); for (const statement of source.statements) { if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { if (ts.isIdentifier(declaration.name)) constNames.add(declaration.name.text); } } } const isFunctionType = (type: ts.TypeNode | undefined): boolean => type !== undefined && (ts.isFunctionTypeNode(type) || ts.isConstructorTypeNode(type)); // Capability-typed members (Effects, Streams, schema codecs, layers, // backends/services) mark a contract, not a data model. const capabilityTypePattern = /\b(Effect|Stream|Layer|Scope|Fiber|Queue|PubSub|Deferred|Ref|SubscriptionRef|SynchronizedRef|Codec|Schema|Context|Runtime)\s*[.<]|\b\w*(Backend|Service|Producer|Consumer|Requestor|Client|Factory|RequestResponse)\b/; const isCapabilityType = (type: ts.TypeNode | undefined): boolean => type !== undefined && capabilityTypePattern.test(type.getText(source)); const isPureDataMembers = (members: ts.NodeArray): boolean => members.length > 0 && members.every( (member) => ts.isPropertySignature(member) && !isFunctionType(member.type) && !isCapabilityType(member.type), ); // Schemas cannot be generic; generic shapes stay structural types. const isGeneric = (typeParameters: ts.NodeArray | undefined): boolean => typeParameters !== undefined && typeParameters.length > 0; // Law 38/39 split: constructor/function option bags stay interfaces. const isOptionBagName = (name: string): boolean => /Options$/.test(name); const record = (node: ts.Node, name: string): void => { const position = source.getLineAndCharacterOfPosition(node.getStart(source)); violations.push({ rule: SCHEMA_FIRST_RULE, path, line: position.line + 1, excerpt: `exported pure-data shape ${name} should be an effect/Schema model`, }); }; const visit = (node: ts.Node): void => { if ( ts.isInterfaceDeclaration(node) && isExported(node) && !isGeneric(node.typeParameters) && node.heritageClauses === undefined && isPureDataMembers(node.members) && !constNames.has(node.name.text) && !isOptionBagName(node.name.text) ) { record(node, node.name.text); } if ( ts.isTypeAliasDeclaration(node) && isExported(node) && !isGeneric(node.typeParameters) && ts.isTypeLiteralNode(node.type) && isPureDataMembers(node.type.members) && !constNames.has(node.name.text) && !isOptionBagName(node.name.text) ) { record(node, node.name.text); } ts.forEachChild(node, visit); }; visit(source); return violations; }; const countKey = (rule: string, path: string): string => `${rule}${path}`; const loadAllowlist = Effect.fn("LawCheck.loadAllowlist")(function* ( fs: FileSystem.FileSystem, ) { const exists = yield* fs.exists(ALLOWLIST_PATH); if (!exists) { return Allowlist.make({ exemptions: [], baseline: [] }); } const raw = yield* fs.readFileString(ALLOWLIST_PATH); return yield* decodeAllowlist(raw).pipe( Effect.mapError((cause) => new LawCheckFailed({ message: `Invalid allowlist at ${ALLOWLIST_PATH}: ${cause.message}` }), ), ); }); const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const writeBaseline = A.contains(Bun.argv, "--write-baseline"); const files = yield* collectSourceFiles(fs); const violations: Violation[] = []; for (const path of files) { const text = yield* fs.readFileString(path); violations.push(...scanRegexRules(path, text)); violations.push(...scanSchemaFirst(path, text)); } const listFilter = A.findFirst(Bun.argv, (arg) => arg.startsWith("--list")); if (O.isSome(listFilter)) { const ruleFilter = listFilter.value.includes("=") ? listFilter.value.split("=")[1] : undefined; const matching = violations.filter((violation) => ruleFilter === undefined || violation.rule === ruleFilter); for (const violation of matching) { yield* Console.log(`${violation.path}:${violation.line} [${violation.rule}] ${violation.excerpt}`); } yield* Console.log(`${matching.length} finding(s).`); return; } const allowlist = yield* loadAllowlist(fs); const exemptionKeys = new Set( allowlist.exemptions.map((entry) => countKey(entry.rule, entry.path)), ); const counted = new Map(); for (const violation of violations) { const key = countKey(violation.rule, violation.path); if (exemptionKeys.has(key)) continue; const existing = counted.get(key) ?? { rule: violation.rule, path: violation.path, count: 0, samples: [] }; existing.count += 1; if (existing.samples.length < 3) existing.samples.push(violation); counted.set(key, existing); } if (writeBaseline) { const baseline = A.sort( [...counted.values()].map(({ rule, path, count }) => BaselineEntry.make({ rule, path, count })), (left: BaselineEntry, right: BaselineEntry) => left.rule < right.rule ? -1 : left.rule > right.rule ? 1 : left.path < right.path ? -1 : left.path > right.path ? 1 : 0, ); const next = Allowlist.make({ exemptions: allowlist.exemptions, baseline }); const encoded = yield* encodeAllowlist(next).pipe( Effect.mapError((cause) => new LawCheckFailed({ message: `Failed to encode allowlist: ${cause.message}` })), ); yield* fs.writeFileString(ALLOWLIST_PATH, `${encoded}\n`); yield* Console.log(`Baseline written: ${baseline.length} entries covering ${violations.length} findings.`); return; } const baselineByKey = new Map( allowlist.baseline.map((entry) => [countKey(entry.rule, entry.path), entry] as const), ); const failures: string[] = []; for (const { rule, path, count, samples } of counted.values()) { const allowed = O.fromUndefinedOr(baselineByKey.get(countKey(rule, path))); const max = O.match(allowed, { onNone: () => 0, onSome: (entry) => entry.count }); if (count > max) { const detail = samples .map((sample) => ` ${sample.path}:${sample.line} ${sample.excerpt}`) .join("\n"); failures.push(` [${rule}] ${path}: ${count} found, ${max} allowed\n${detail}`); } } for (const entry of allowlist.baseline) { const live = counted.get(countKey(entry.rule, entry.path)); const count = live?.count ?? 0; if (count < entry.count) { failures.push( ` [stale-baseline] ${entry.path} [${entry.rule}]: baseline allows ${entry.count} but only ${count} remain — ratchet down with --write-baseline`, ); } } if (failures.length > 0) { yield* Console.error(`Effect law violations:\n${failures.join("\n")}`); return yield* Effect.fail(new LawCheckFailed({ message: `${failures.length} law-check failure(s)` })); } const baselineTotal = allowlist.baseline.reduce((sum, entry) => sum + entry.count, 0); yield* Console.log( `Effect laws clean: ${files.length} files checked, baseline debt ${baselineTotal} across ${allowlist.baseline.length} entries, ${allowlist.exemptions.length} documented exemptions.`, ); }).pipe(Effect.provide(BunFileSystem.layer)); BunRuntime.runMain(program);