#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; const moduleRoot = path.resolve(import.meta.dirname, '..'); const sourceRoots = { main: path.join(moduleRoot, 'src', 'main', 'java'), test: path.join(moduleRoot, 'src', 'test', 'java'), }; const internalPrefix = 'com.baiyue.firstleg.'; const sliceRules = [ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], ['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/], ]; function parseArgs(argv) { const options = { sourceSet: 'main', format: 'json', output: null, check: null }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--source-set') options.sourceSet = argv[++index]; else if (argument === '--format') options.format = argv[++index]; else if (argument === '--output') options.output = argv[++index]; else if (argument === '--check') options.check = argv[++index]; else if (argument === '--help') { process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); process.exit(0); } else throw new Error(`Unknown argument: ${argument}`); } if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); return options; } function walkJavaFiles(root) { if (!fs.existsSync(root)) return []; const result = []; const pending = [root]; while (pending.length > 0) { const current = pending.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const absolute = path.join(current, entry.name); if (entry.isDirectory()) pending.push(absolute); else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); } } return result.sort(); } function stripCommentsAndStrings(source) { return source .replace(/\/\*[\s\S]*?\*\//g, ' ') .replace(/\/\/[^\r\n]*/g, ' ') .replace(/"(?:\\.|[^"\\])*"/g, '""') .replace(/'(?:\\.|[^'\\])*'/g, "''"); } function classifySlice(fqcn) { for (const [slice, expression] of sliceRules) { if (expression.test(fqcn)) return slice; } return 'shared-technical'; } function classifyKind(fqcn) { const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); if (packageName.includes('.client')) return 'cross-service-adapter'; if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; return 'business-application'; } function parseSource(file, sourceSet) { const raw = fs.readFileSync(file, 'utf8'); const source = stripCommentsAndStrings(raw); const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; const typeName = path.basename(file, '.java'); if (!packageName) throw new Error(`Missing package declaration: ${file}`); const fqcn = `${packageName}.${typeName}`; const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); return { file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), sourceSet, packageName, typeName, fqcn, imports, source, slice: classifySlice(fqcn), kind: classifyKind(fqcn), }; } function collectSources(sourceSet) { const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); } function resolveDependencies(sources) { const knownTypes = new Map(sources.map(source => [source.fqcn, source])); const typesByPackage = new Map(); for (const source of sources) { if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); typesByPackage.get(source.packageName).push(source); } const dependencies = []; const seen = new Set(); for (const source of sources) { const candidates = new Set(); for (const imported of source.imports) { if (knownTypes.has(imported)) candidates.add(imported); else if (imported.startsWith(internalPrefix)) { const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); if (owner) candidates.add(owner); } } for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { candidates.add(samePackageType.fqcn); } } for (const targetFqcn of candidates) { if (targetFqcn === source.fqcn) continue; const key = `${source.fqcn}->${targetFqcn}`; if (seen.has(key)) continue; seen.add(key); dependencies.push({ source: source.fqcn, target: targetFqcn }); } } return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); } function aggregateEdges(dependencies, nodeOf) { const counts = new Map(); for (const dependency of dependencies) { const source = nodeOf(dependency.source); const target = nodeOf(dependency.target); if (source === target) continue; const key = `${source}\0${target}`; counts.set(key, (counts.get(key) ?? 0) + 1); } return [...counts].map(([key, count]) => { const [source, target] = key.split('\0'); return { source, target, count }; }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); } function stronglyConnectedComponents(nodes, edges) { const adjacency = new Map(nodes.map(node => [node, []])); for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); const indexByNode = new Map(); const lowLink = new Map(); const stack = []; const onStack = new Set(); const components = []; let nextIndex = 0; function visit(node) { indexByNode.set(node, nextIndex); lowLink.set(node, nextIndex); nextIndex += 1; stack.push(node); onStack.add(node); for (const target of adjacency.get(node) ?? []) { if (!indexByNode.has(target)) { visit(target); lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); } else if (onStack.has(target)) { lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); } } if (lowLink.get(node) === indexByNode.get(node)) { const component = []; let current; do { current = stack.pop(); onStack.delete(current); component.push(current); } while (current !== node); components.push(component.sort()); } } for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); } function buildReport(sourceSet) { const sources = collectSources(sourceSet); const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); const dependencies = resolveDependencies(sources); const packages = [...new Set(sources.map(source => source.packageName))].sort(); const slices = [...new Set(sources.map(source => source.slice))].sort(); const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); const packageSccs = stronglyConnectedComponents(packages, packageEdges); const sliceSccs = stronglyConnectedComponents(slices, sliceEdges); const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind)); const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); return { schemaVersion: 1, generatedFrom: sourceSet, moduleRoot: 'firstleg', summary: { javaFiles: sources.length, internalClassDependencies: dependencies.length, packages: packages.length, slices: slices.length, crossSliceClassDependencies: directCrossSliceDependencies.length, crossSliceModelLeaks: modelLeaks.length, crossSliceMapperLeaks: mapperLeaks.length, }, sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), classes: sources.map(({ source, ...entry }) => entry), classDependencies: dependencies, packageEdges, sliceEdges, packageSccs, sliceSccs, directCrossSliceDependencies, modelLeaks, mapperLeaks, }; } function renderMarkdown(report) { const lines = [ '# Firstleg internal dependency report', '', `- Source set: ${report.generatedFrom}`, `- Java files: ${report.summary.javaFiles}`, `- Internal class dependencies: ${report.summary.internalClassDependencies}`, `- Package SCCs (>1 node): ${report.packageSccs.length}`, `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), '', '## Slice SCCs', '', ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), '', '## Package SCCs', '', ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), '', '## Cross-slice model and mapper leaks', '', ...[...report.modelLeaks, ...report.mapperLeaks] .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index) .map(edge => `- \`${edge.source}\` -> \`${edge.target}\``), '', ]; return lines.join('\n'); } function renderDot(report) { const lines = ['digraph firstleg_slices {', ' rankdir=LR;']; for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); lines.push('}', ''); return lines.join('\n'); } function checkContract(report, contractPath) { const absolute = path.resolve(process.cwd(), contractPath); const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); const allowed = new Set(contract.allowedSliceEdges ?? []); const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const additions = actual.filter(edge => !allowed.has(edge)); const violations = additions.map(edge => `new slice dependency: ${edge}`); const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { const actualClass = classesByName.get(fqcn); if (!actualClass) violations.push(`required class missing: ${fqcn}`); else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); } const applicationRules = contract.applicationDependencyRules ?? (contract.applicationDependencyRule ? [contract.applicationDependencyRule] : []); for (const applicationRule of applicationRules) { const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); for (const entry of applicationClasses) { for (const imported of entry.imports) { if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); } } } for (const edge of report.classDependencies) { if (edge.source.startsWith(applicationRule.packagePrefix) && edge.target.startsWith(internalPrefix) && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); } } } for (const exposureRule of contract.sliceExposureRules ?? []) { for (const edge of report.classDependencies) { if (!edge.source.startsWith(exposureRule.slicePrefix) && edge.target.startsWith(exposureRule.slicePrefix) && !(exposureRule.allowedTargetPrefixes ?? []).some(prefix => edge.target.startsWith(prefix))) { violations.push(`forbidden slice implementation dependency: ${edge.source} -> ${edge.target}`); } } } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); } if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); } } if (violations.length > 0) { process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); process.exitCode = 1; } } const options = parseArgs(process.argv.slice(2)); const report = buildReport(options.sourceSet); if (options.check) checkContract(report, options.check); const rendered = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : options.format === 'markdown' ? renderMarkdown(report) : renderDot(report); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered);