diff --git a/scripts/msw-dep-graph.ts b/scripts/msw-dep-graph.ts new file mode 100644 index 0000000..fc00a5b --- /dev/null +++ b/scripts/msw-dep-graph.ts @@ -0,0 +1,341 @@ +#!/usr/bin/env bun +/** + * MSW Dependency Graph Analyzer + * + * Static analysis of the shared/msw import graph across features. + * Detects: + * 1. Dependency tree per backend-api-msw module + * 2. Diamond dependencies (A -> B,C both -> D) + * 3. Route collisions (two features defining handlers for same HTTP method + path) + * + * Usage: bun scripts/msw-dep-graph.ts + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { resolve, relative, dirname } from 'node:path' + +const ROOT = resolve(import.meta.dir, '..') +const CODEBASE = resolve(ROOT, 'codebase') +const FEATURES = resolve(CODEBASE, 'features') + +// -- Types -- + +interface DepNode { + feature: string + module: string + imports: DepNode[] +} + +interface RouteSignature { + method: string + path: string + feature: string + file: string +} + +interface Diamond { + root: string + pathA: string[] + pathB: string[] + shared: string +} + +// -- Helpers -- + +function featureName(filePath: string): string { + const rel = relative(FEATURES, filePath) + return rel.split('/')[0] +} + +function extractMswImports(filePath: string): string[] { + const content = readFileSync(filePath, 'utf-8') + const importRegex = /import\s+\{[^}]+\}\s+from\s+['"]([^'"]+)['"]/g + const imports: string[] = [] + + let match: RegExpExecArray | null + while ((match = importRegex.exec(content)) !== null) { + const specifier = match[1] + + if (specifier.startsWith('.') || specifier.startsWith('@features/')) { + let resolved: string + + if (specifier.startsWith('@features/')) { + const featurePath = specifier.replace('@features/', '') + resolved = resolve(FEATURES, featurePath) + } else { + resolved = resolve(dirname(filePath), specifier) + } + + if (resolved.includes('shared/msw')) { + imports.push(resolved) + } + } + } + + return imports +} + +function extractRoutes(filePath: string): RouteSignature[] { + const content = readFileSync(filePath, 'utf-8') + const feature = featureName(filePath) + const routes: RouteSignature[] = [] + + const handlerRegex = /http\.(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi + + let match: RegExpExecArray | null + while ((match = handlerRegex.exec(content)) !== null) { + routes.push({ + method: match[1].toUpperCase(), + path: match[2], + feature, + file: relative(ROOT, filePath), + }) + } + + return routes +} + +// -- Scanning -- + +function findBackendApiMswFiles(): string[] { + const results: string[] = [] + const featureDirs = readdirSync(FEATURES, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + + for (const feat of featureDirs) { + const handlersFile = resolve(FEATURES, feat, 'backend-api-msw/src/handlers.ts') + if (existsSync(handlersFile)) { + results.push(handlersFile) + } + } + + return results +} + +function findSharedMswFiles(): string[] { + const results: string[] = [] + const featureDirs = readdirSync(FEATURES, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + + for (const feat of featureDirs) { + const mswDir = resolve(FEATURES, feat, 'shared/msw') + if (!existsSync(mswDir)) continue + + const entries = readdirSync(mswDir, { withFileTypes: true }) + for (const f of entries) { + if (f.isFile() && f.name.endsWith('.ts')) { + results.push(resolve(mswDir, f.name)) + } + if (f.isDirectory()) { + const subEntries = readdirSync(resolve(mswDir, f.name), { withFileTypes: true }) + for (const sf of subEntries) { + if (sf.isFile() && sf.name.endsWith('.ts')) { + results.push(resolve(mswDir, f.name, sf.name)) + } + } + } + } + } + + return results +} + +function buildDepTree(handlerFile: string, visited: Set = new Set()): DepNode { + const feat = featureName(handlerFile) + const rel = relative(resolve(FEATURES, feat), handlerFile) + const node: DepNode = { + feature: feat, + module: rel.startsWith('backend-api-msw') ? 'backend-api-msw' : 'shared/msw', + imports: [], + } + + if (visited.has(handlerFile)) return node + visited.add(handlerFile) + + const imports = extractMswImports(handlerFile) + for (const imp of imports) { + let resolvedFile = imp + if (!resolvedFile.endsWith('.ts')) { + if (existsSync(resolvedFile + '.ts')) { + resolvedFile = resolvedFile + '.ts' + } else if (existsSync(resolve(resolvedFile, 'index.ts'))) { + resolvedFile = resolve(resolvedFile, 'index.ts') + } else { + continue + } + } + + const childNode = buildDepTree(resolvedFile, visited) + node.imports.push(childNode) + } + + return node +} + +// -- Diamond Detection -- + +function detectDiamonds(tree: DepNode): Diamond[] { + const diamonds: Diamond[] = [] + const allLeaves = new Map() + + function collectPaths(node: DepNode, path: string[]): void { + const key = `${node.feature}/${node.module}` + const currentPath = [...path, key] + + if (node.imports.length === 0) { + const existing = allLeaves.get(key) || [] + existing.push(currentPath.join(' -> ')) + allLeaves.set(key, existing) + } + + for (const child of node.imports) { + collectPaths(child, currentPath) + } + } + + collectPaths(tree, []) + + for (const [leaf, paths] of allLeaves) { + if (paths.length > 1) { + diamonds.push({ + root: `${tree.feature}/${tree.module}`, + pathA: paths[0].split(' -> '), + pathB: paths[1].split(' -> '), + shared: leaf, + }) + } + } + + return diamonds +} + +// -- Route Collision Detection -- + +function normalizeRoute(path: string): string { + return path + .replace(/^\*/, '') + .replace(/:[^/]+/g, ':param') + .replace(/\{[^}]+\}/g, ':param') +} + +function detectCollisions(routes: RouteSignature[]): Array<{ a: RouteSignature; b: RouteSignature }> { + const collisions: Array<{ a: RouteSignature; b: RouteSignature }> = [] + const bySignature = new Map() + + for (const route of routes) { + const key = `${route.method} ${normalizeRoute(route.path)}` + const existing = bySignature.get(key) || [] + existing.push(route) + bySignature.set(key, existing) + } + + for (const [, group] of bySignature) { + const features = new Set(group.map(r => r.feature)) + if (features.size > 1) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + if (group[i].feature !== group[j].feature) { + collisions.push({ a: group[i], b: group[j] }) + } + } + } + } + } + + return collisions +} + +// -- Main -- + +const backendApiMswFiles = findBackendApiMswFiles() +const sharedMswFiles = findSharedMswFiles() + +console.log('MSW Dependency Graph Analysis') +console.log('='.repeat(60)) +console.log() + +console.log(`Found ${backendApiMswFiles.length} backend-api-msw modules\n`) + +const allDiamonds: Diamond[] = [] + +for (const file of backendApiMswFiles) { + const tree = buildDepTree(file) + console.log(` ${tree.feature}/backend-api-msw`) + + for (let i = 0; i < tree.imports.length; i++) { + const child = tree.imports[i] + const isLast = i === tree.imports.length - 1 + const connector = isLast ? ' +-- ' : ' |-- ' + const childIndent = isLast ? ' ' : ' | ' + + console.log(`${connector}${child.feature}/shared/msw`) + + for (let j = 0; j < child.imports.length; j++) { + const grandchild = child.imports[j] + const gcIsLast = j === child.imports.length - 1 + const gcConnector = gcIsLast ? '+-- ' : '|-- ' + console.log(`${childIndent}${gcConnector}${grandchild.feature}/shared/msw`) + } + } + + console.log() + + const diamonds = detectDiamonds(tree) + allDiamonds.push(...diamonds) +} + +// Diamond results +console.log('-- Diamond Dependencies ' + '-'.repeat(37)) +if (allDiamonds.length === 0) { + console.log(' No diamond dependencies detected') +} else { + console.log(` ${allDiamonds.length} diamond(s) detected:\n`) + for (const d of allDiamonds) { + console.log(` Diamond in ${d.root}:`) + console.log(` Path A: ${d.pathA.join(' -> ')}`) + console.log(` Path B: ${d.pathB.join(' -> ')}`) + console.log(` Shared: ${d.shared}`) + console.log() + } +} +console.log() + +// Route collisions +console.log('-- Route Collisions ' + '-'.repeat(41)) + +const allRoutes: RouteSignature[] = [] + +for (const file of sharedMswFiles) { + allRoutes.push(...extractRoutes(file)) +} +for (const file of backendApiMswFiles) { + allRoutes.push(...extractRoutes(file)) +} + +const collisions = detectCollisions(allRoutes) + +if (collisions.length === 0) { + console.log(' No cross-feature route collisions detected') +} else { + console.log(` ${collisions.length} collision(s) detected:\n`) + for (const c of collisions) { + console.log(` ${c.a.method} ${c.a.path}`) + console.log(` Feature A: ${c.a.feature} (${c.a.file})`) + console.log(` Feature B: ${c.b.feature} (${c.b.file})`) + console.log() + } +} + +console.log() +console.log('-- Summary ' + '-'.repeat(49)) +console.log(` Backend API MSW modules: ${backendApiMswFiles.length}`) +console.log(` Shared MSW files: ${sharedMswFiles.length}`) +console.log(` Route signatures found: ${allRoutes.length}`) +console.log(` Diamond dependencies: ${allDiamonds.length}`) +console.log(` Route collisions: ${collisions.length}`) + +if (allDiamonds.length > 0 || collisions.length > 0) { + process.exit(1) +}