trustgraph/ts/scripts/check-effect-laws.ts
elpresidank 0746d7ffd5 feat(ts): add real quality gates — Biome lint + effect-law ratchet + class inventory
- biome.json (2.4.16, linter-only) wired as "lint" in all six packages
- scripts/check-effect-laws.ts: Effect-native law enforcement encoding the
  adapted beep-effect effect-first/schema-first laws (no native JSON/switch/
  sort/fetch/timers, no process.env, no throw new, no Effect.run* outside
  boundaries, no Schema-suffixed constants, no node:fs/path, AST-based
  pure-data interface detection per law 38/39)
- ratcheting baseline allowlist (95 entries / 290 findings) that must shrink
  to documented exemptions only; stale counts fail the gate
- root lint chains turbo lint + law check + native-class inventory
- fix all 163 initial Biome findings: import-type style, templates, two `any`s,
  ten non-null assertions (librarian getService gate, A.matchRight in atoms,
  ensureNode returning nodes, main.tsx mount guard)

Gates: lint, check:tsgo, build, test (force, 11 tasks) all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:40:01 -05:00

343 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}${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 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);