Path: blob/main/package/src/common/import-report/report-bundle-async-cycles.ts
6452 views
/*1* report-bundle-async-cycles.ts2*3* Detects when esbuild marks modules as async due to transitive top-level await,4* and those async modules are in import cycles, causing bundling errors.5*6* Copyright (C) 2024 Posit Software, PBC7*/89import { existsSync } from "../../../../src/deno_ral/fs.ts";10import { resolve } from "../../../../src/deno_ral/path.ts";11import { architectureToolsPath } from "../../../../src/core/resources.ts";12import { Parser } from "npm:[email protected]";13import { simple } from "npm:[email protected]";14import solver from "npm:javascript-lp-solver";1516interface AsyncModule {17name: string;18path: string;19}2021async function generateCycles(entryPoint: string): Promise<string> {22const timestamp = Date.now();23const cyclesFile = `/tmp/cycles-${timestamp}.toon`;2425console.log("Generating cycle data...");2627const denoBinary = Deno.env.get("QUARTO_DENO") || architectureToolsPath("deno");28const scriptPath = resolve("package/src/common/import-report/explain-all-cycles.ts");29const importMapPath = resolve("src/import_map.json");3031const process = Deno.run({32cmd: [33denoBinary,34"run",35"--allow-read",36"--allow-write",37"--allow-env",38"--allow-net",39"--allow-run",40"--allow-import",41"--import-map",42importMapPath,43scriptPath,44entryPoint,45"--simplify",46"--toon",47cyclesFile,48],49stdout: "piped",50stderr: "piped",51});5253const status = await process.status();5455if (!status.success) {56const error = new TextDecoder().decode(await process.stderrOutput());57throw new Error(`Failed to generate cycles: ${error}`);58}5960process.close();6162return cyclesFile;63}6465function parseCyclesFile(cyclesFile: string): Set<string> {66const content = Deno.readTextFileSync(cyclesFile);67const lines = content.split("\n");68const filesInCycles = new Set<string>();6970// Parse TOON format: edges[N]{from,to}: followed by " from,to" lines71for (const line of lines) {72if (line.startsWith(" ")) {73const [from, to] = line.trim().split(",");74if (from) filesInCycles.add(from);75if (to) filesInCycles.add(to);76}77}7879return filesInCycles;80}8182function findAsyncModules(bundleCode: string): AsyncModule[] {83// Pattern: var init_foo = __esm({ async "path/to/file.ts"() {84const asyncWrapperPattern = /var (init_\w+) = __esm\(\{\s*async\s+"([^"]+)"\(\)/g;8586const asyncModules: AsyncModule[] = [];87for (const match of bundleCode.matchAll(asyncWrapperPattern)) {88asyncModules.push({89name: match[1],90path: match[2],91});92}9394return asyncModules;95}9697function findAllModules(bundleCode: string): AsyncModule[] {98// Pattern: var init_foo = __esm({ "path/to/file.ts"() { OR async "path"() {99const wrapperPattern = /var (init_\w+) = __esm\(\{\s*(?:async\s+)?"([^"]+)"\(\)/g;100101const allModules: AsyncModule[] = [];102for (const match of bundleCode.matchAll(wrapperPattern)) {103allModules.push({104name: match[1],105path: match[2],106});107}108109return allModules;110}111112function findRootAsyncModules(bundleCode: string, asyncModules: AsyncModule[]): AsyncModule[] {113// Root async modules are those marked async but don't await OTHER init_*() functions114// They have the actual top-level await (e.g., await wasm_default())115const rootModules: AsyncModule[] = [];116117for (const { name, path } of asyncModules) {118// Find where this wrapper is defined119// Format: var init_foo = __esm({ async "path"() { ... }});120const startPattern = new RegExp(121`var ${name} = __esm\\(\\{\\s*async\\s+"[^"]+?"\\(\\)\\s*\\{`,122's'123);124const startMatch = startPattern.exec(bundleCode);125126if (!startMatch) continue;127128// Extract code after the wrapper starts129const startIndex = startMatch.index + startMatch[0].length;130const remainder = bundleCode.slice(startIndex, startIndex + 10000);131132// Find where THIS wrapper ends (closing "});")133const endPattern = /^\}\);/m;134const endMatch = endPattern.exec(remainder);135136if (!endMatch) continue;137138// Only check the body of THIS wrapper, not subsequent code139const wrapperBody = remainder.slice(0, endMatch.index);140141// Check if this wrapper body calls await init_*()142const awaitInitPattern = /await init_\w+\(\)/;143const hasAwaitInit = awaitInitPattern.test(wrapperBody);144145if (!hasAwaitInit) {146// This is a root async module - it's async but doesn't await other inits147rootModules.push({ name, path });148}149}150151return rootModules;152}153154function simplifyPath(path: string): string {155// Remove common prefixes for display156if (path.includes("/src/")) {157return path.slice(path.indexOf("/src/") + 1);158}159if (path.startsWith("https://")) {160// Show just the meaningful part of URLs161if (path.length > 60) {162return "..." + path.slice(-57);163}164}165return path;166}167168169function reverseGraph(graph: Map<string, Set<string>>): Map<string, Set<string>> {170// Build reverse graph: A imports B becomes B is imported by A171const reversed = new Map<string, Set<string>>();172173// Initialize all nodes174for (const node of graph.keys()) {175reversed.set(node, new Set<string>());176}177178// Reverse all edges179for (const [from, toSet] of graph.entries()) {180for (const to of toSet) {181if (!reversed.has(to)) {182reversed.set(to, new Set<string>());183}184reversed.get(to)!.add(from);185}186}187188return reversed;189}190191function buildAsyncPropagationGraphFromAST(192bundleCode: string,193asyncModules: AsyncModule[],194allModules: AsyncModule[]195): Map<string, Set<string>> {196// Build map: init_name -> module info (use allModules, not just asyncModules)197const initToModule = new Map<string, AsyncModule>();198for (const mod of allModules) {199initToModule.set(mod.name, mod);200}201202const graph = new Map<string, Set<string>>();203204// Parse each wrapper individually to avoid issues with invalid syntax in some wrappers205for (const { name, path } of allModules) {206// Find this wrapper's definition (with or without async keyword)207const wrapperPattern = new RegExp(208`var ${name} = __esm\\(\\{\\s*(?:async\\s+)?"[^"]+?"\\(\\)\\s*\\{`,209's'210);211const match = wrapperPattern.exec(bundleCode);212if (!match) {213continue;214}215216// Extract the wrapper body (up to the closing "});")217const startIndex = match.index + match[0].length;218const remainder = bundleCode.slice(startIndex, startIndex + 50000);219const endPattern = /^\}\);/m;220const endMatch = endPattern.exec(remainder);221if (!endMatch) {222continue;223}224225const wrapperBody = remainder.slice(0, endMatch.index);226227try {228// Try to parse just this wrapper's body as a function229const functionCode = `async function temp() {\n${wrapperBody}\n}`;230const ast = Parser.parse(functionCode, {231ecmaVersion: "latest",232sourceType: "module",233});234235// Find all init_*() calls (both awaited and non-awaited)236const deps = new Set<string>();237simple(ast, {238CallExpression(callNode: any) {239if (240callNode.callee?.type === "Identifier" &&241callNode.callee.name.startsWith("init_")242) {243const depName = callNode.callee.name;244const depModule = initToModule.get(depName);245if (depModule) {246deps.add(simplifyPath(depModule.path));247}248}249},250});251252graph.set(simplifyPath(path), deps);253} catch (e) {254// If this wrapper has syntax errors (the exact issue we're tracking!),255// fall back to regex-based extraction256const initPattern = /\b(init_\w+)\(\)/g;257const deps = new Set<string>();258259for (const initMatch of wrapperBody.matchAll(initPattern)) {260const depName = initMatch[1];261const depModule = initToModule.get(depName);262if (depModule) {263deps.add(simplifyPath(depModule.path));264}265}266267graph.set(simplifyPath(path), deps);268}269}270271return graph;272}273274function normalizePath(path: string, filesInCycles: Set<string>): string {275// Normalize to match cycle file format (relative from src/)276const simplified = simplifyPath(path);277278// Try exact match first279if (filesInCycles.has(simplified)) {280return simplified;281}282283// Try without src/ prefix284if (simplified.startsWith("src/")) {285const withoutSrc = simplified.slice(4);286if (filesInCycles.has(withoutSrc)) {287return withoutSrc;288}289}290291return simplified;292}293294function tracePaths(295graph: Map<string, Set<string>>,296rootAsync: string,297filesInCycles: Set<string>298): Map<string, string[]> {299const paths = new Map<string, string[]>();300const queue: string[][] = [[rootAsync]];301const visited = new Set<string>();302303while (queue.length > 0) {304const path = queue.shift()!;305const current = path[path.length - 1];306307// Create a normalized version for cycle checking308const normalizedCurrent = normalizePath(current, filesInCycles);309310if (visited.has(current)) continue;311visited.add(current);312313// If current is in a cycle, record the path314if (filesInCycles.has(normalizedCurrent)) {315// Only record if we don't have a shorter path to this file316if (!paths.has(normalizedCurrent) || path.length < paths.get(normalizedCurrent)!.length) {317paths.set(normalizedCurrent, path);318}319continue; // Don't traverse further into cycles320}321322// Add neighbors to queue323const deps = graph.get(current);324if (deps) {325for (const dep of deps) {326if (!visited.has(dep)) {327queue.push([...path, dep]);328}329}330}331}332333return paths;334}335336interface BreakPoint {337file: string;338imports: string;339cycleEntry: string;340affectedFiles: string[];341}342343interface Edge {344from: string;345to: string;346}347348function reverseChains(paths: Map<string, string[]>): string[][] {349const chains: string[][] = [];350for (const [_, path] of paths.entries()) {351chains.push([...path].reverse());352}353return chains;354}355356function buildILPModel(chains: string[][]) {357// Extract all unique edges358const edgeSet = new Set<string>();359const edges: Edge[] = [];360361for (const chain of chains) {362for (let i = 0; i < chain.length - 1; i++) {363const edgeKey = `${chain[i]}→${chain[i + 1]}`;364if (!edgeSet.has(edgeKey)) {365edgeSet.add(edgeKey);366edges.push({ from: chain[i], to: chain[i + 1] });367}368}369}370371// Build ILP model372const constraints: Record<string, { min: number }> = {};373const variables: Record<string, any> = {};374const ints: Record<string, 1> = {};375376// One constraint per chain: must break at least one edge377chains.forEach((chain, idx) => {378constraints[`chain_${idx}`] = { min: 1 };379});380381// One variable per edge382edges.forEach((edge, idx) => {383const edgeId = `edge_${idx}`;384385variables[edgeId] = {386cost: 1, // Minimize number of edges387};388389// Mark which chains contain this edge390chains.forEach((chain, chainIdx) => {391for (let i = 0; i < chain.length - 1; i++) {392if (chain[i] === edge.from && chain[i + 1] === edge.to) {393variables[edgeId][`chain_${chainIdx}`] = 1;394break;395}396}397});398399// Force binary (0 or 1)400ints[edgeId] = 1;401});402403return {404optimize: "cost",405opType: "min",406constraints,407variables,408ints,409edges410};411}412413function solveMinimumEdgeCut(chains: string[][]): Edge[] {414if (chains.length === 0) {415return [];416}417418const model = buildILPModel(chains);419const result = solver.Solve(model);420421// Extract which edges to remove422const edgesToRemove: Edge[] = [];423model.edges.forEach((edge, idx) => {424const edgeId = `edge_${idx}`;425if (result[edgeId] === 1) {426edgesToRemove.push(edge);427}428});429430return edgesToRemove;431}432433function convertToBreakPoints(434edges: Edge[],435allPaths: Map<string, string[]>436): BreakPoint[] {437const breakPointMap = new Map<string, Set<string>>();438439// For each edge to remove, find which cycle files it affects440for (const edge of edges) {441const edgeKey = `${edge.from}→${edge.to}`;442443if (!breakPointMap.has(edgeKey)) {444breakPointMap.set(edgeKey, new Set());445}446447// Find all paths containing this edge448for (const [cycleFile, path] of allPaths.entries()) {449const reversedPath = [...path].reverse();450for (let i = 0; i < reversedPath.length - 1; i++) {451if (reversedPath[i] === edge.from && reversedPath[i + 1] === edge.to) {452breakPointMap.get(edgeKey)!.add(cycleFile);453break;454}455}456}457}458459// Convert to BreakPoint format460const breakPoints: BreakPoint[] = [];461for (const [edgeKey, affectedFiles] of breakPointMap.entries()) {462const [file, imports] = edgeKey.split("→");463breakPoints.push({464file,465imports,466cycleEntry: imports,467affectedFiles: Array.from(affectedFiles)468});469}470471// Sort by number of affected files (descending)472breakPoints.sort((a, b) => b.affectedFiles.length - a.affectedFiles.length);473474return breakPoints;475}476477function identifyBreakPoints(478paths: Map<string, string[]>,479filesInCycles: Set<string>480): BreakPoint[] {481const breakPoints: BreakPoint[] = [];482const breakPointMap = new Map<string, Set<string>>(); // edge -> affected cycle files483484for (const [cycleFile, path] of paths.entries()) {485if (path.length < 2) continue; // Need at least root -> cycleEntry486487// The break point is the edge from the last non-cycle file to the first cycle file488const cycleEntryIndex = path.findIndex((p) => {489const normalized = normalizePath(p, filesInCycles);490return filesInCycles.has(normalized);491});492493if (cycleEntryIndex > 0) {494// In the reversed graph, the path shows: root → ... → lastNonCycle → firstCycle → ...495// We need to find the LAST file NOT in the cycle (which imports the first cycle file)496497const cycleEntry = path[cycleEntryIndex]; // First file in cycle498499// Find the last non-cycle file by going backwards from cycleEntry500let lastNonCycleIndex = cycleEntryIndex - 1;501while (lastNonCycleIndex >= 0) {502const candidate = path[lastNonCycleIndex];503const normalized = normalizePath(candidate, filesInCycles);504if (!filesInCycles.has(normalized)) {505// Found a non-cycle file!506// In reversed graph: ... → lastNonCycle → cycleEntry → ...507// In forward terms: cycleEntry imports lastNonCycle508// So the break is: in cycleEntry, make import of lastNonCycle dynamic509const edge = `${cycleEntry}→${candidate}`;510511if (!breakPointMap.has(edge)) {512breakPointMap.set(edge, new Set());513}514breakPointMap.get(edge)!.add(cycleFile);515break;516}517lastNonCycleIndex--;518}519}520}521522// Convert to BreakPoint objects523for (const [edge, affectedFiles] of breakPointMap.entries()) {524const [file, imports] = edge.split("→");525const cycleEntry = imports;526breakPoints.push({527file,528imports,529cycleEntry,530affectedFiles: Array.from(affectedFiles),531});532}533534// Sort by number of affected files (descending)535breakPoints.sort((a, b) => b.affectedFiles.length - a.affectedFiles.length);536537return breakPoints;538}539540function findCycles(541graph: Map<string, Set<string>>,542maxCycles: number = 1000543): string[][] {544const cycles: string[][] = [];545const visited = new Set<string>();546const recStack = new Set<string>();547const path: string[] = [];548549function dfs(node: string): boolean {550if (cycles.length >= maxCycles) return true;551552visited.add(node);553recStack.add(node);554path.push(node);555556const neighbors = graph.get(node) || new Set();557for (const neighbor of neighbors) {558if (cycles.length >= maxCycles) return true;559560if (!visited.has(neighbor)) {561if (dfs(neighbor)) return true;562} else if (recStack.has(neighbor)) {563// Found a cycle!564const cycleStart = path.indexOf(neighbor);565if (cycleStart !== -1) {566const cycle = path.slice(cycleStart);567cycle.push(neighbor); // Complete the cycle568cycles.push(cycle);569}570}571}572573path.pop();574recStack.delete(node);575return false;576}577578// Try DFS from each node579for (const node of graph.keys()) {580if (cycles.length >= maxCycles) break;581if (!visited.has(node)) {582dfs(node);583}584}585586return cycles;587}588589function buildMFASModel(590graph: Map<string, Set<string>>,591cycles: string[][]592) {593// Extract all edges from graph594const edgeSet = new Set<string>();595const edges: Edge[] = [];596597for (const [from, toSet] of graph.entries()) {598for (const to of toSet) {599const edgeKey = `${from}→${to}`;600if (!edgeSet.has(edgeKey)) {601edgeSet.add(edgeKey);602edges.push({ from, to });603}604}605}606607// Build ILP model (same structure as Set Cover)608const constraints: Record<string, { min: number }> = {};609const variables: Record<string, any> = {};610const ints: Record<string, 1> = {};611612// One constraint per cycle: at least one edge must be removed613cycles.forEach((cycle, cycleIdx) => {614constraints[`cycle_${cycleIdx}`] = { min: 1 };615});616617// One variable per edge618edges.forEach((edge, edgeIdx) => {619const edgeId = `edge_${edgeIdx}`;620621variables[edgeId] = {622cost: 1, // Minimize number of edges removed623};624625// Mark which cycles contain this edge626cycles.forEach((cycle, cycleIdx) => {627// Check if edge is in this cycle628for (let i = 0; i < cycle.length - 1; i++) {629if (cycle[i] === edge.from && cycle[i + 1] === edge.to) {630variables[edgeId][`cycle_${cycleIdx}`] = 1;631break;632}633}634});635636// Force binary (0 or 1)637ints[edgeId] = 1;638});639640return {641optimize: "cost",642opType: "min",643constraints,644variables,645ints,646edges647};648}649650function solveMFAS(651graph: Map<string, Set<string>>,652asyncInCycles: AsyncModule[]653): Edge[] {654if (graph.size === 0 || asyncInCycles.length === 0) {655return [];656}657658// Build subgraph: async modules in cycles + their immediate neighbors659const asyncPaths = new Set<string>();660for (const { path } of asyncInCycles) {661asyncPaths.add(simplifyPath(path));662}663664const subgraph = new Map<string, Set<string>>();665666// Add all async module nodes and their edges667for (const asyncPath of asyncPaths) {668if (graph.has(asyncPath)) {669subgraph.set(asyncPath, new Set(graph.get(asyncPath)!));670}671}672673// Add edges from any node to async modules (immediate neighbors)674for (const [from, toSet] of graph.entries()) {675for (const to of toSet) {676if (asyncPaths.has(to)) {677if (!subgraph.has(from)) {678subgraph.set(from, new Set());679}680subgraph.get(from)!.add(to);681}682}683}684685console.log(`Built subgraph: ${asyncPaths.size} async modules + ${subgraph.size - asyncPaths.size} neighbors`);686687// Find cycles in the subgraph688const maxCycles = 1000;689console.log("Finding cycles in subgraph...");690const allCycles = findCycles(subgraph, maxCycles);691692// Filter to only cycles that contain at least one async module693const cycles = allCycles.filter(cycle =>694cycle.some(node => asyncPaths.has(node))695);696697if (cycles.length === 0) {698return [];699}700701console.log(`✓ Found ${cycles.length} cycle(s) containing async modules`);702if (cycles.length < allCycles.length) {703console.log(` (filtered from ${allCycles.length} total cycles in subgraph)`);704}705706// Warn if we hit the cycle limit707if (allCycles.length >= maxCycles) {708console.log(`⚠️ Warning: Reached maximum cycle limit (${maxCycles})`);709console.log(` Solution may not be globally optimal - only considering ${maxCycles} cycles\n`);710}711712// Build and solve ILP model on the subgraph713const model = buildMFASModel(subgraph, cycles);714const result = solver.Solve(model);715716if (!result || !result.feasible) {717console.log("⚠️ MFAS solver could not find a solution");718return [];719}720721// Extract which edges to remove722const edgesToRemove: Edge[] = [];723model.edges.forEach((edge, idx) => {724const edgeId = `edge_${idx}`;725if (result[edgeId] === 1) {726edgesToRemove.push(edge);727}728});729730return edgesToRemove;731}732733function formatMFASRecommendations(edges: Edge[]): string {734if (edges.length === 0) {735return "✅ No edges need to be removed (graph is already acyclic)\n";736}737738const lines: string[] = [];739lines.push("=== ALTERNATIVE: BREAK CYCLES DIRECTLY ===\n");740lines.push(`Instead of breaking async propagation chains, you could break`);741lines.push(`the cycles themselves by making ${edges.length} import(s) dynamic:\n`);742743for (let i = 0; i < edges.length; i++) {744const edge = edges[i];745lines.push(`${i + 1}. File: ${simplifyPath(edge.from)}`);746lines.push(` Currently imports: ${simplifyPath(edge.to)}`);747lines.push(` 💡 Make this import dynamic to help break cycles\n`);748}749750lines.push("This represents the minimum feedback arc set (MFAS) -");751lines.push("the minimum number of edges to remove to make the graph acyclic.\n");752753return lines.join("\n");754}755756function formatChainRecommendations(757breakPoints: BreakPoint[],758rootModules: AsyncModule[]759): string {760if (breakPoints.length === 0) {761return "✅ No actionable break points identified.\n This may mean the async chains have already been broken.\n";762}763764const lines: string[] = [];765lines.push("=== IMPORT CHAIN ANALYSIS ===\n");766767if (rootModules.length > 0) {768lines.push("Root async modules (files with actual top-level await):");769for (const { path } of rootModules.slice(0, 3)) {770lines.push(` • ${simplifyPath(path)}`);771}772lines.push("");773}774775lines.push(`Found ${breakPoints.length} recommended break point(s):\n`);776777for (let i = 0; i < breakPoints.length; i++) {778const bp = breakPoints[i];779lines.push(`${i + 1}. Break point (affects ${bp.affectedFiles.length} cyclic file(s)):\n`);780lines.push(` File: ${simplifyPath(bp.file)}`);781lines.push(` Currently imports: ${simplifyPath(bp.imports)}`);782lines.push(` Cycle entry: ${simplifyPath(bp.cycleEntry)}\n`);783lines.push(" 💡 Recommendation:");784lines.push(" Make this import dynamic to break the async propagation chain");785lines.push(" before it reaches the cyclic code.\n");786lines.push(` Affected cyclic files (${bp.affectedFiles.length}):`);787for (const file of bp.affectedFiles.slice(0, 5)) {788lines.push(` • ${file}`);789}790if (bp.affectedFiles.length > 5) {791lines.push(` ... and ${bp.affectedFiles.length - 5} more`);792}793lines.push("");794}795796return lines.join("\n");797}798799if (import.meta.main) {800console.log("=== Bundle Async-Cycles Detector ===\n");801802// Check for bundle803const bundlePath = "package/pkg-working/bin/quarto.js";804if (!existsSync(bundlePath)) {805console.error("❌ Bundle not found at:", bundlePath);806console.error("\nPlease run prepare-dist first:");807console.error(" cd package && ./scripts/common/prepare-dist.sh");808console.error("\nSee ~/bin/try-dist for more details.");809Deno.exit(1);810}811812console.log("✓ Found bundle at:", bundlePath);813814// Read the bundle815const bundleCode = Deno.readTextFileSync(bundlePath);816console.log(`✓ Bundle size: ${(bundleCode.length / 1024 / 1024).toFixed(1)} MB\n`);817818// Find all modules and async modules819console.log("Analyzing modules in bundle...");820const allModules = findAllModules(bundleCode);821const asyncModules = findAsyncModules(bundleCode);822console.log(`✓ Found ${allModules.length} total modules (${asyncModules.length} async)\n`);823824if (asyncModules.length === 0) {825console.log("✅ No async modules found. Bundle is clean!");826Deno.exit(0);827}828829// Find root async modules830const rootModules = findRootAsyncModules(bundleCode, asyncModules);831832console.log("=== ROOT ASYNC MODULES ===");833console.log("(Modules with actual top-level await)\n");834835if (rootModules.length === 0) {836console.log("⚠️ Could not identify root modules (may be in a cycle)");837} else {838console.log(`Found ${rootModules.length} root async modules:`);839for (const { name, path } of rootModules) {840console.log(` ${simplifyPath(path)}`);841}842}843console.log();844845// Generate cycles846const entryPoint = Deno.args[0] || "src/quarto.ts";847const cyclesFile = await generateCycles(entryPoint);848console.log(`✓ Generated cycles data\n`);849850// Parse cycles851const filesInCycles = parseCyclesFile(cyclesFile);852console.log(`✓ Found ${filesInCycles.size} files in cycles\n`);853854// Find intersection: async modules that are in cycles855const asyncInCycles: AsyncModule[] = [];856const asyncPaths = new Set<string>();857858for (const { name, path } of asyncModules) {859asyncPaths.add(path);860861// Check if this path or simplified version is in cycles862const simplified = simplifyPath(path);863if (filesInCycles.has(path) || filesInCycles.has(simplified)) {864asyncInCycles.push({ name, path });865}866}867868// Results869console.log("=== ASYNC MODULES IN CYCLES ===");870871if (asyncInCycles.length === 0) {872console.log("✅ No async modules found in cycles!");873console.log(" The bundle should not have async initialization issues.\n");874} else {875console.log("⚠️ Found async modules in import cycles:");876console.log(" These are POTENTIALLY problematic - they cause build failures if they have");877console.log(" cycles among themselves. The MFAS analysis below will check for this.\n");878879for (const { name, path } of asyncInCycles) {880console.log(` ${name.padEnd(30)} ${simplifyPath(path)}`);881}882console.log();883}884885// Build complete dependency graph from bundle (shows ALL init_* dependencies)886console.log("Building dependency graph from bundle...");887const fullGraph = buildAsyncPropagationGraphFromAST(bundleCode, asyncModules, allModules);888console.log(`✓ Built dependency graph (${fullGraph.size} modules)`);889890// Reverse the graph to trace async propagation (A imports B → B imported by A)891console.log("Reversing graph to trace async propagation...");892const reverseFullGraph = reverseGraph(fullGraph);893console.log(`✓ Built reverse graph (${reverseFullGraph.size} modules)\n`);894895// For each root async module, trace BACKWARDS through importers to find cyclic files896if (rootModules.length > 0 && asyncInCycles.length > 0) {897console.log("=== TRACING ASYNC PROPAGATION CHAINS ===\n");898899const allPaths = new Map<string, string[]>();900901for (const { path: rootPath } of rootModules) {902const paths = tracePaths(reverseFullGraph, simplifyPath(rootPath), filesInCycles);903904for (const [cycleFile, path] of paths.entries()) {905// Keep the shortest path to each cycle file906if (!allPaths.has(cycleFile) || path.length < allPaths.get(cycleFile)!.length) {907allPaths.set(cycleFile, path);908}909}910}911912console.log(`Found ${allPaths.size} paths from root async modules to cyclic files\n`);913914// Add ILP optimization step915console.log("=== OPTIMIZING BREAK POINTS WITH ILP ===\n");916const reversedChains = reverseChains(allPaths);917console.log(`Solving for minimum edge cut across ${reversedChains.length} chains...`);918919const optimalEdges = solveMinimumEdgeCut(reversedChains);920console.log(`✓ Optimal solution: ${optimalEdges.length} edge(s) to remove\n`);921922// Convert ILP solution to break points923const breakPoints = convertToBreakPoints(optimalEdges, allPaths);924925// Format and display recommendations926const recommendations = formatChainRecommendations(breakPoints, rootModules);927console.log(recommendations);928929// Add MFAS alternative analysis930console.log("\n=== ALTERNATIVE APPROACH: MINIMUM FEEDBACK ARC SET ===\n");931console.log("Analyzing cycles containing async modules...");932933const mfasEdges = solveMFAS(fullGraph, asyncInCycles);934935if (mfasEdges.length > 0) {936console.log(`✓ Minimum feedback arc set: ${mfasEdges.length} edge(s)\n`);937const mfasRecommendations = formatMFASRecommendations(mfasEdges);938console.log(mfasRecommendations);939console.log("💡 TIP: If you cannot fix all recommended edges at once:");940console.log(" 1. Fix some of the recommended dynamic imports");941console.log(" 2. Rebuild the bundle");942console.log(" 3. Run this tool again - the recommendations may change!");943console.log(" Breaking some cycles can eliminate others, reducing the total work needed.\n");944} else {945console.log("✅ No cycles found entirely among async modules!");946console.log(" The async modules are in cycles with non-async code, which is probably fine.");947console.log(" Build should succeed without issues.\n");948}949} else if (asyncInCycles.length === 0) {950console.log("✅ No async modules in cycles - no chain analysis needed.\n");951} else {952console.log("⚠️ Could not trace chains (no root async modules identified)\n");953}954955// Cleanup956try {957Deno.removeSync(cyclesFile);958} catch {959// Ignore cleanup errors960}961}962963964