mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
/**
|
||
* 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} |