trustgraph/ts/scripts/check-effect-laws.ts

393 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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>()(
"LawCheckFailed",
{ message: S.String },
) {}
class BaselineEntry extends S.Class<BaselineEntry>("BaselineEntry")({
rule: S.String,
path: S.String,
count: S.Number,
}) {}
class Exemption extends S.Class<Exemption>("Exemption")({
rule: S.String,
path: S.String,
reason: S.String,
}) {}
class Allowlist extends S.Class<Allowlist>("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<RegexRule> = [
{
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<Violation> => {
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<Violation> => {
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<string>();
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<ts.TypeElement>): 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<ts.TypeParameterDeclaration> | 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<string, { rule: string; path: string; count: number; samples: Violation[] }>();
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);