mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
344 lines
12 KiB
TypeScript
344 lines
12 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;
|
|||
|
|
|
|||
|
|
const isFunctionType = (type: ts.TypeNode | undefined): boolean =>
|
|||
|
|
type !== undefined && (ts.isFunctionTypeNode(type) || ts.isConstructorTypeNode(type));
|
|||
|
|
|
|||
|
|
const isPureDataMembers = (members: ts.NodeArray<ts.TypeElement>): boolean =>
|
|||
|
|
members.length > 0 &&
|
|||
|
|
members.every(
|
|||
|
|
(member) =>
|
|||
|
|
ts.isPropertySignature(member) &&
|
|||
|
|
!isFunctionType(member.type),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
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) && isPureDataMembers(node.members)) {
|
|||
|
|
record(node, node.name.text);
|
|||
|
|
}
|
|||
|
|
if (
|
|||
|
|
ts.isTypeAliasDeclaration(node) &&
|
|||
|
|
isExported(node) &&
|
|||
|
|
ts.isTypeLiteralNode(node.type) &&
|
|||
|
|
isPureDataMembers(node.type.members)
|
|||
|
|
) {
|
|||
|
|
record(node, node.name.text);
|
|||
|
|
}
|
|||
|
|
ts.forEachChild(node, visit);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
visit(source);
|
|||
|
|
return violations;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const countKey = (rule: string, path: string): string => `${rule} |