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 };