139 lines
4.1 KiB
TypeScript
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;
|
|
}
|