341 lines
9.2 KiB
TypeScript
341 lines
9.2 KiB
TypeScript
#!/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<string> = 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<string, string[]>()
|
|
|
|
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<string, RouteSignature[]>()
|
|
|
|
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)
|
|
}
|