Spaces:
Runtime error
Runtime error
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| const cwd = process.cwd(); | |
| const defaultRoots = [ | |
| "src/shared/components", | |
| "src/lib/db", | |
| "src/lib/compliance", | |
| "open-sse/translator", | |
| "open-sse/mcp-server", | |
| ]; | |
| const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultRoots; | |
| const sourceExtensions = [".ts", ".tsx", ".js", ".mjs", ".jsx", ".mts", ".cts"]; | |
| function toPosix(filePath) { | |
| return filePath.split(path.sep).join("/"); | |
| } | |
| function listSourceFiles(rootDir) { | |
| const absRoot = path.resolve(cwd, rootDir); | |
| if (!fs.existsSync(absRoot)) { | |
| return []; | |
| } | |
| const stack = [absRoot]; | |
| const files = []; | |
| while (stack.length > 0) { | |
| const current = stack.pop(); | |
| const entries = fs.readdirSync(current, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(current, entry.name); | |
| if (entry.isDirectory()) { | |
| stack.push(fullPath); | |
| continue; | |
| } | |
| if (sourceExtensions.includes(path.extname(entry.name))) { | |
| files.push(path.resolve(fullPath)); | |
| } | |
| } | |
| } | |
| return files; | |
| } | |
| function resolveRelativeImport(fromFile, specifier) { | |
| const base = path.resolve(path.dirname(fromFile), specifier); | |
| const ext = path.extname(base); | |
| if (ext && fs.existsSync(base) && fs.statSync(base).isFile()) { | |
| return path.resolve(base); | |
| } | |
| for (const extension of sourceExtensions) { | |
| const candidate = `${base}${extension}`; | |
| if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { | |
| return path.resolve(candidate); | |
| } | |
| } | |
| for (const extension of sourceExtensions) { | |
| const candidate = path.join(base, `index${extension}`); | |
| if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { | |
| return path.resolve(candidate); | |
| } | |
| } | |
| return null; | |
| } | |
| function extractImportSpecifiers(fileContents) { | |
| const specs = []; | |
| const regex = /\b(?:import|export)\s+(?:[^"'`]*?\sfrom\s*)?["'`]([^"'`]+)["'`]/g; | |
| let match = regex.exec(fileContents); | |
| while (match) { | |
| specs.push(match[1]); | |
| match = regex.exec(fileContents); | |
| } | |
| return specs; | |
| } | |
| function buildGraph(files) { | |
| const fileSet = new Set(files); | |
| const graph = new Map(); | |
| for (const filePath of files) { | |
| const code = fs.readFileSync(filePath, "utf8"); | |
| const dependencies = new Set(); | |
| const importSpecifiers = extractImportSpecifiers(code); | |
| for (const specifier of importSpecifiers) { | |
| if (!specifier.startsWith(".")) continue; | |
| const resolved = resolveRelativeImport(filePath, specifier); | |
| if (!resolved) continue; | |
| if (!fileSet.has(resolved)) continue; | |
| dependencies.add(resolved); | |
| } | |
| graph.set(filePath, dependencies); | |
| } | |
| return graph; | |
| } | |
| function stronglyConnectedComponents(graph) { | |
| const indexMap = new Map(); | |
| const lowLinkMap = new Map(); | |
| const onStack = new Set(); | |
| const stack = []; | |
| const components = []; | |
| let indexCounter = 0; | |
| function strongConnect(node) { | |
| indexMap.set(node, indexCounter); | |
| lowLinkMap.set(node, indexCounter); | |
| indexCounter += 1; | |
| stack.push(node); | |
| onStack.add(node); | |
| for (const neighbor of graph.get(node) || []) { | |
| if (!indexMap.has(neighbor)) { | |
| strongConnect(neighbor); | |
| lowLinkMap.set(node, Math.min(lowLinkMap.get(node), lowLinkMap.get(neighbor))); | |
| } else if (onStack.has(neighbor)) { | |
| lowLinkMap.set(node, Math.min(lowLinkMap.get(node), indexMap.get(neighbor))); | |
| } | |
| } | |
| if (lowLinkMap.get(node) === indexMap.get(node)) { | |
| const component = []; | |
| while (stack.length > 0) { | |
| const candidate = stack.pop(); | |
| onStack.delete(candidate); | |
| component.push(candidate); | |
| if (candidate === node) break; | |
| } | |
| components.push(component); | |
| } | |
| } | |
| for (const node of graph.keys()) { | |
| if (!indexMap.has(node)) { | |
| strongConnect(node); | |
| } | |
| } | |
| return components; | |
| } | |
| function isSelfCycle(component, graph) { | |
| if (component.length !== 1) return false; | |
| const [file] = component; | |
| return (graph.get(file) || new Set()).has(file); | |
| } | |
| const files = roots.flatMap((root) => listSourceFiles(root)); | |
| const graph = buildGraph(files); | |
| const components = stronglyConnectedComponents(graph); | |
| const cycles = components.filter( | |
| (component) => component.length > 1 || isSelfCycle(component, graph) | |
| ); | |
| if (cycles.length === 0) { | |
| console.log( | |
| `[cycles] OK - no cycles detected across ${graph.size} files in: ${roots.join(", ")}` | |
| ); | |
| process.exit(0); | |
| } | |
| console.error(`[cycles] FAIL - detected ${cycles.length} strongly connected component(s):`); | |
| for (const component of cycles) { | |
| const sorted = [...component].sort((a, b) => a.localeCompare(b)); | |
| console.error(`\n- SCC (${sorted.length} files)`); | |
| for (const filePath of sorted) { | |
| console.error(` - ${toPosix(path.relative(cwd, filePath))}`); | |
| } | |
| } | |
| process.exit(1); | |