Files
clean-architecture-frontend…/scripts/lib/realtime-boundaries.ts
T

139 lines
4.1 KiB
TypeScript

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
export type RealtimeBoundaryRuleId =
| "NATIVE_REALTIME_API_OUTSIDE_ADAPTER"
| "PRESENTATION_INTERVAL_OWNER"
| "UNSELECTED_REALTIME_RUNTIME_COMPOSED";
export type RealtimeBoundaryViolation = Readonly<{
ruleId: RealtimeBoundaryRuleId;
file: string;
line: number;
}>;
const SOURCE_EXTENSION = /\.(?:[cm]?ts|tsx)$/u;
const OWNED_NATIVE_ROOTS = [
"src/adapters/realtime/",
"src/adapters/web-push/",
] as const;
const REALTIME_ADAPTER_IMPORT =
/(?:from\s*|import\s*\()\s*["'][^"']*\/adapters\/(?:realtime|web-push)(?:\/[^"']*)?["']/gu;
const NATIVE_REALTIME_PATTERNS = [
/\bnew\s+(?:WebSocket|EventSource|Notification)\s*\(/gu,
/\bNotification\s*\.\s*requestPermission\s*\(/gu,
/\.\s*showNotification\s*\(/gu,
/\.\s*pushManager\s*\.\s*(?:subscribe|getSubscription)\s*\(/gu,
/\bReflect\s*\.\s*get\s*\([^,]+,\s*["'](?:WebSocket|EventSource|Notification|pushManager)["']/gu,
] as const;
const PRESENTATION_INTERVAL = /\bsetInterval\s*\(/gu;
export async function scanRealtimeBoundaries(
sourceRoot: string,
): Promise<readonly RealtimeBoundaryViolation[]> {
const absoluteRoot = path.resolve(sourceRoot);
const files = await collectSourceFiles(absoluteRoot);
const violations: RealtimeBoundaryViolation[] = [];
for (const file of files) {
const source = await readFile(file, "utf8");
const logicalFile = logicalSourcePath(absoluteRoot, file);
inspectFile(source, logicalFile, violations);
}
return Object.freeze(
violations
.sort(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.ruleId.localeCompare(right.ruleId),
)
.map((violation) => Object.freeze(violation)),
);
}
function inspectFile(
source: string,
logicalFile: string,
violations: RealtimeBoundaryViolation[],
): void {
const nativeOwned = OWNED_NATIVE_ROOTS.some((root) =>
logicalFile.startsWith(root),
);
const presentationOwned =
logicalFile.startsWith("src/presentation/") ||
/^src\/features\/[^/]+\/presentation\//u.test(logicalFile);
const compositionBoundary =
logicalFile.startsWith("src/bootstrap/") ||
/^src\/features\/installed-feature-/u.test(logicalFile);
const report = (
ruleId: RealtimeBoundaryRuleId,
index: number,
): void => {
violations.push({
ruleId,
file: logicalFile,
line: lineAt(source, index),
});
};
if (!nativeOwned) {
for (const pattern of NATIVE_REALTIME_PATTERNS) {
for (const match of source.matchAll(pattern)) {
report(
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
match.index,
);
}
}
}
if (presentationOwned) {
for (const match of source.matchAll(PRESENTATION_INTERVAL)) {
report("PRESENTATION_INTERVAL_OWNER", match.index);
}
}
if (compositionBoundary) {
for (const match of source.matchAll(REALTIME_ADAPTER_IMPORT)) {
report("UNSELECTED_REALTIME_RUNTIME_COMPOSED", match.index);
}
}
}
async function collectSourceFiles(
directory: string,
): Promise<readonly string[]> {
const output: string[] = [];
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const resolved = path.join(directory, entry.name);
if (
entry.isDirectory() &&
!["node_modules", "dist", "artifacts", ".tmp"].includes(
entry.name,
)
) {
output.push(...(await collectSourceFiles(resolved)));
} else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) {
output.push(resolved);
}
}
return output;
}
function logicalSourcePath(root: string, file: string): string {
const workspaceRelative = path
.relative(process.cwd(), file)
.split(path.sep)
.join("/");
if (root === path.resolve("src")) return workspaceRelative;
return path.relative(root, file).split(path.sep).join("/");
}
function lineAt(source: string, index: number): number {
let line = 1;
for (let offset = 0; offset < index; offset += 1) {
if (source.charCodeAt(offset) === 10) line += 1;
}
return line;
}