Files
frontend/apps/sn/scripts/check-module-boundaries.cjs
T
2026-09-15 10:00:42 +09:00

166 lines
5.3 KiB
JavaScript

const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
// 2~4차가 아직 사용하는 기존 기반 helper만 파일·모듈 쌍으로 좁게 허용한다.
const LEGACY_DEPENDENCIES = {
"src/features/dredging/api/dredging-api.ts": ["@/components/lib/api"],
"src/features/dredging/api/photo-upload.ts": ["@/components/lib/utils"],
"src/features/dredging/lib/use-dredging-common-codes.ts": [
"@/components/lib/common/apiHelpers",
],
};
const LEGACY_DIRECTORY = "app/(with-base-layout)/oper/dredging/lib/";
const LEGACY_FILES = [
"client",
"types",
"uuid",
"date",
"exif",
"registration-request",
"useDredgingCommonCodes",
].map((name) => `${LEGACY_DIRECTORY}${name}.ts`);
/** 정적·동적 import, export-from, require의 문자열 모듈 참조를 수집한다. */
function moduleSpecifiers(sourceFile) {
const modules = [];
function visit(node) {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
modules.push(node.moduleSpecifier.text);
}
if (
ts.isCallExpression(node) &&
node.arguments.length &&
ts.isStringLiteral(node.arguments[0]) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) &&
node.expression.text === "require"))
) {
modules.push(node.arguments[0].text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return modules;
}
/** 새 feature의 모델·기반과 legacy 진입점에만 검증 범위를 한정한다. */
function validateSource(relativePath, content) {
const sourceFile = ts.createSourceFile(
relativePath,
content,
ts.ScriptTarget.Latest,
true,
);
const errors = [];
if (LEGACY_FILES.includes(relativePath)) {
if (
!sourceFile.statements.length ||
sourceFile.statements.some(
(statement) =>
!ts.isExportDeclaration(statement) ||
!statement.moduleSpecifier ||
!ts.isStringLiteral(statement.moduleSpecifier) ||
!statement.moduleSpecifier.text.startsWith("@/features/dredging/"),
)
) {
errors.push(
`${relativePath}: legacy 파일은 준설 feature 재수출만 허용합니다.`,
);
}
}
for (const specifier of moduleSpecifiers(sourceFile)) {
const resolved = specifier.startsWith("@/")
? `src/${specifier.slice(2)}`
: specifier.startsWith(".")
? path.posix.normalize(
path.posix.join(path.posix.dirname(relativePath), specifier),
)
: specifier;
if (
/^src\/features\/(dredging|mapLayers|gisManagement|gisEdit)\//.test(relativePath)
) {
if (specifier.startsWith("@/app/") || resolved.startsWith("app/")) {
errors.push(`${relativePath}: app 라우트 역참조 금지 (${specifier})`);
}
if (
specifier.startsWith("@/components/") &&
!(LEGACY_DEPENDENCIES[relativePath] ?? []).includes(specifier)
) {
errors.push(
`${relativePath}: 승인되지 않은 legacy 기반 의존 (${specifier})`,
);
}
if (
relativePath.includes("/model/") &&
/(?:^|\/)(?:api|ui|app|components)(?:\/|$)/.test(resolved)
) {
errors.push(
`${relativePath}: model이 API/UI/라우트에 의존할 수 없습니다 (${specifier})`,
);
}
}
if (
relativePath === "src/shared/api/http-transport.ts" &&
/^(?:src\/)?(?:features|entities|app|components)\//.test(resolved)
) {
errors.push(
`${relativePath}: transport의 상위 계층 역참조 금지 (${specifier})`,
);
}
}
return errors;
}
/** 신규 feature만 재귀 검사한다. 타 앱·공용 패키지의 부채는 이 게이트에 혼합하지 않는다. */
function sourceFiles(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const filename = path.join(directory, entry.name);
return entry.isDirectory()
? sourceFiles(filename)
: /\.tsx?$/.test(entry.name)
? [filename]
: [];
});
}
function checkWorkspace(root) {
const files = [
...LEGACY_FILES.map((relative) => path.join(root, relative)),
...sourceFiles(path.join(root, "src/features/dredging")),
...sourceFiles(path.join(root, "src/features/mapLayers")),
...sourceFiles(path.join(root, "src/features/gisManagement")),
...sourceFiles(path.join(root, "src/features/gisEdit")),
path.join(root, "src/shared/api/http-transport.ts"),
];
const errors = files.flatMap((filename) =>
validateSource(
path.relative(root, filename).split(path.sep).join("/"),
fs.readFileSync(filename, "utf8"),
),
);
return {
checkedFiles: files.length,
legacyDependencies: LEGACY_DEPENDENCIES,
errors,
};
}
if (require.main === module) {
const result = checkWorkspace(path.resolve(__dirname, ".."));
console.log(
`SN module boundaries: ${result.checkedFiles} files, ${result.errors.length} violations`,
);
console.log(
"Retained legacy bridges: dredging-api -> API utility; photo-upload -> TUS utility; common-code hook -> CMS helper",
);
for (const error of result.errors) console.error(error);
if (result.errors.length) process.exitCode = 1;
}
module.exports = { validateSource, checkWorkspace };