feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user