Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/package/src/common/import-report/report-bundle-async-cycles.ts
6452 views
1
/*
2
* report-bundle-async-cycles.ts
3
*
4
* Detects when esbuild marks modules as async due to transitive top-level await,
5
* and those async modules are in import cycles, causing bundling errors.
6
*
7
* Copyright (C) 2024 Posit Software, PBC
8
*/
9
10
import { existsSync } from "../../../../src/deno_ral/fs.ts";
11
import { resolve } from "../../../../src/deno_ral/path.ts";
12
import { architectureToolsPath } from "../../../../src/core/resources.ts";
13
import { Parser } from "npm:[email protected]";
14
import { simple } from "npm:[email protected]";
15
import solver from "npm:javascript-lp-solver";
16
17
interface AsyncModule {
18
name: string;
19
path: string;
20
}
21
22
async function generateCycles(entryPoint: string): Promise<string> {
23
const timestamp = Date.now();
24
const cyclesFile = `/tmp/cycles-${timestamp}.toon`;
25
26
console.log("Generating cycle data...");
27
28
const denoBinary = Deno.env.get("QUARTO_DENO") || architectureToolsPath("deno");
29
const scriptPath = resolve("package/src/common/import-report/explain-all-cycles.ts");
30
const importMapPath = resolve("src/import_map.json");
31
32
const process = Deno.run({
33
cmd: [
34
denoBinary,
35
"run",
36
"--allow-read",
37
"--allow-write",
38
"--allow-env",
39
"--allow-net",
40
"--allow-run",
41
"--allow-import",
42
"--import-map",
43
importMapPath,
44
scriptPath,
45
entryPoint,
46
"--simplify",
47
"--toon",
48
cyclesFile,
49
],
50
stdout: "piped",
51
stderr: "piped",
52
});
53
54
const status = await process.status();
55
56
if (!status.success) {
57
const error = new TextDecoder().decode(await process.stderrOutput());
58
throw new Error(`Failed to generate cycles: ${error}`);
59
}
60
61
process.close();
62
63
return cyclesFile;
64
}
65
66
function parseCyclesFile(cyclesFile: string): Set<string> {
67
const content = Deno.readTextFileSync(cyclesFile);
68
const lines = content.split("\n");
69
const filesInCycles = new Set<string>();
70
71
// Parse TOON format: edges[N]{from,to}: followed by " from,to" lines
72
for (const line of lines) {
73
if (line.startsWith(" ")) {
74
const [from, to] = line.trim().split(",");
75
if (from) filesInCycles.add(from);
76
if (to) filesInCycles.add(to);
77
}
78
}
79
80
return filesInCycles;
81
}
82
83
function findAsyncModules(bundleCode: string): AsyncModule[] {
84
// Pattern: var init_foo = __esm({ async "path/to/file.ts"() {
85
const asyncWrapperPattern = /var (init_\w+) = __esm\(\{\s*async\s+"([^"]+)"\(\)/g;
86
87
const asyncModules: AsyncModule[] = [];
88
for (const match of bundleCode.matchAll(asyncWrapperPattern)) {
89
asyncModules.push({
90
name: match[1],
91
path: match[2],
92
});
93
}
94
95
return asyncModules;
96
}
97
98
function findAllModules(bundleCode: string): AsyncModule[] {
99
// Pattern: var init_foo = __esm({ "path/to/file.ts"() { OR async "path"() {
100
const wrapperPattern = /var (init_\w+) = __esm\(\{\s*(?:async\s+)?"([^"]+)"\(\)/g;
101
102
const allModules: AsyncModule[] = [];
103
for (const match of bundleCode.matchAll(wrapperPattern)) {
104
allModules.push({
105
name: match[1],
106
path: match[2],
107
});
108
}
109
110
return allModules;
111
}
112
113
function findRootAsyncModules(bundleCode: string, asyncModules: AsyncModule[]): AsyncModule[] {
114
// Root async modules are those marked async but don't await OTHER init_*() functions
115
// They have the actual top-level await (e.g., await wasm_default())
116
const rootModules: AsyncModule[] = [];
117
118
for (const { name, path } of asyncModules) {
119
// Find where this wrapper is defined
120
// Format: var init_foo = __esm({ async "path"() { ... }});
121
const startPattern = new RegExp(
122
`var ${name} = __esm\\(\\{\\s*async\\s+"[^"]+?"\\(\\)\\s*\\{`,
123
's'
124
);
125
const startMatch = startPattern.exec(bundleCode);
126
127
if (!startMatch) continue;
128
129
// Extract code after the wrapper starts
130
const startIndex = startMatch.index + startMatch[0].length;
131
const remainder = bundleCode.slice(startIndex, startIndex + 10000);
132
133
// Find where THIS wrapper ends (closing "});")
134
const endPattern = /^\}\);/m;
135
const endMatch = endPattern.exec(remainder);
136
137
if (!endMatch) continue;
138
139
// Only check the body of THIS wrapper, not subsequent code
140
const wrapperBody = remainder.slice(0, endMatch.index);
141
142
// Check if this wrapper body calls await init_*()
143
const awaitInitPattern = /await init_\w+\(\)/;
144
const hasAwaitInit = awaitInitPattern.test(wrapperBody);
145
146
if (!hasAwaitInit) {
147
// This is a root async module - it's async but doesn't await other inits
148
rootModules.push({ name, path });
149
}
150
}
151
152
return rootModules;
153
}
154
155
function simplifyPath(path: string): string {
156
// Remove common prefixes for display
157
if (path.includes("/src/")) {
158
return path.slice(path.indexOf("/src/") + 1);
159
}
160
if (path.startsWith("https://")) {
161
// Show just the meaningful part of URLs
162
if (path.length > 60) {
163
return "..." + path.slice(-57);
164
}
165
}
166
return path;
167
}
168
169
170
function reverseGraph(graph: Map<string, Set<string>>): Map<string, Set<string>> {
171
// Build reverse graph: A imports B becomes B is imported by A
172
const reversed = new Map<string, Set<string>>();
173
174
// Initialize all nodes
175
for (const node of graph.keys()) {
176
reversed.set(node, new Set<string>());
177
}
178
179
// Reverse all edges
180
for (const [from, toSet] of graph.entries()) {
181
for (const to of toSet) {
182
if (!reversed.has(to)) {
183
reversed.set(to, new Set<string>());
184
}
185
reversed.get(to)!.add(from);
186
}
187
}
188
189
return reversed;
190
}
191
192
function buildAsyncPropagationGraphFromAST(
193
bundleCode: string,
194
asyncModules: AsyncModule[],
195
allModules: AsyncModule[]
196
): Map<string, Set<string>> {
197
// Build map: init_name -> module info (use allModules, not just asyncModules)
198
const initToModule = new Map<string, AsyncModule>();
199
for (const mod of allModules) {
200
initToModule.set(mod.name, mod);
201
}
202
203
const graph = new Map<string, Set<string>>();
204
205
// Parse each wrapper individually to avoid issues with invalid syntax in some wrappers
206
for (const { name, path } of allModules) {
207
// Find this wrapper's definition (with or without async keyword)
208
const wrapperPattern = new RegExp(
209
`var ${name} = __esm\\(\\{\\s*(?:async\\s+)?"[^"]+?"\\(\\)\\s*\\{`,
210
's'
211
);
212
const match = wrapperPattern.exec(bundleCode);
213
if (!match) {
214
continue;
215
}
216
217
// Extract the wrapper body (up to the closing "});")
218
const startIndex = match.index + match[0].length;
219
const remainder = bundleCode.slice(startIndex, startIndex + 50000);
220
const endPattern = /^\}\);/m;
221
const endMatch = endPattern.exec(remainder);
222
if (!endMatch) {
223
continue;
224
}
225
226
const wrapperBody = remainder.slice(0, endMatch.index);
227
228
try {
229
// Try to parse just this wrapper's body as a function
230
const functionCode = `async function temp() {\n${wrapperBody}\n}`;
231
const ast = Parser.parse(functionCode, {
232
ecmaVersion: "latest",
233
sourceType: "module",
234
});
235
236
// Find all init_*() calls (both awaited and non-awaited)
237
const deps = new Set<string>();
238
simple(ast, {
239
CallExpression(callNode: any) {
240
if (
241
callNode.callee?.type === "Identifier" &&
242
callNode.callee.name.startsWith("init_")
243
) {
244
const depName = callNode.callee.name;
245
const depModule = initToModule.get(depName);
246
if (depModule) {
247
deps.add(simplifyPath(depModule.path));
248
}
249
}
250
},
251
});
252
253
graph.set(simplifyPath(path), deps);
254
} catch (e) {
255
// If this wrapper has syntax errors (the exact issue we're tracking!),
256
// fall back to regex-based extraction
257
const initPattern = /\b(init_\w+)\(\)/g;
258
const deps = new Set<string>();
259
260
for (const initMatch of wrapperBody.matchAll(initPattern)) {
261
const depName = initMatch[1];
262
const depModule = initToModule.get(depName);
263
if (depModule) {
264
deps.add(simplifyPath(depModule.path));
265
}
266
}
267
268
graph.set(simplifyPath(path), deps);
269
}
270
}
271
272
return graph;
273
}
274
275
function normalizePath(path: string, filesInCycles: Set<string>): string {
276
// Normalize to match cycle file format (relative from src/)
277
const simplified = simplifyPath(path);
278
279
// Try exact match first
280
if (filesInCycles.has(simplified)) {
281
return simplified;
282
}
283
284
// Try without src/ prefix
285
if (simplified.startsWith("src/")) {
286
const withoutSrc = simplified.slice(4);
287
if (filesInCycles.has(withoutSrc)) {
288
return withoutSrc;
289
}
290
}
291
292
return simplified;
293
}
294
295
function tracePaths(
296
graph: Map<string, Set<string>>,
297
rootAsync: string,
298
filesInCycles: Set<string>
299
): Map<string, string[]> {
300
const paths = new Map<string, string[]>();
301
const queue: string[][] = [[rootAsync]];
302
const visited = new Set<string>();
303
304
while (queue.length > 0) {
305
const path = queue.shift()!;
306
const current = path[path.length - 1];
307
308
// Create a normalized version for cycle checking
309
const normalizedCurrent = normalizePath(current, filesInCycles);
310
311
if (visited.has(current)) continue;
312
visited.add(current);
313
314
// If current is in a cycle, record the path
315
if (filesInCycles.has(normalizedCurrent)) {
316
// Only record if we don't have a shorter path to this file
317
if (!paths.has(normalizedCurrent) || path.length < paths.get(normalizedCurrent)!.length) {
318
paths.set(normalizedCurrent, path);
319
}
320
continue; // Don't traverse further into cycles
321
}
322
323
// Add neighbors to queue
324
const deps = graph.get(current);
325
if (deps) {
326
for (const dep of deps) {
327
if (!visited.has(dep)) {
328
queue.push([...path, dep]);
329
}
330
}
331
}
332
}
333
334
return paths;
335
}
336
337
interface BreakPoint {
338
file: string;
339
imports: string;
340
cycleEntry: string;
341
affectedFiles: string[];
342
}
343
344
interface Edge {
345
from: string;
346
to: string;
347
}
348
349
function reverseChains(paths: Map<string, string[]>): string[][] {
350
const chains: string[][] = [];
351
for (const [_, path] of paths.entries()) {
352
chains.push([...path].reverse());
353
}
354
return chains;
355
}
356
357
function buildILPModel(chains: string[][]) {
358
// Extract all unique edges
359
const edgeSet = new Set<string>();
360
const edges: Edge[] = [];
361
362
for (const chain of chains) {
363
for (let i = 0; i < chain.length - 1; i++) {
364
const edgeKey = `${chain[i]}→${chain[i + 1]}`;
365
if (!edgeSet.has(edgeKey)) {
366
edgeSet.add(edgeKey);
367
edges.push({ from: chain[i], to: chain[i + 1] });
368
}
369
}
370
}
371
372
// Build ILP model
373
const constraints: Record<string, { min: number }> = {};
374
const variables: Record<string, any> = {};
375
const ints: Record<string, 1> = {};
376
377
// One constraint per chain: must break at least one edge
378
chains.forEach((chain, idx) => {
379
constraints[`chain_${idx}`] = { min: 1 };
380
});
381
382
// One variable per edge
383
edges.forEach((edge, idx) => {
384
const edgeId = `edge_${idx}`;
385
386
variables[edgeId] = {
387
cost: 1, // Minimize number of edges
388
};
389
390
// Mark which chains contain this edge
391
chains.forEach((chain, chainIdx) => {
392
for (let i = 0; i < chain.length - 1; i++) {
393
if (chain[i] === edge.from && chain[i + 1] === edge.to) {
394
variables[edgeId][`chain_${chainIdx}`] = 1;
395
break;
396
}
397
}
398
});
399
400
// Force binary (0 or 1)
401
ints[edgeId] = 1;
402
});
403
404
return {
405
optimize: "cost",
406
opType: "min",
407
constraints,
408
variables,
409
ints,
410
edges
411
};
412
}
413
414
function solveMinimumEdgeCut(chains: string[][]): Edge[] {
415
if (chains.length === 0) {
416
return [];
417
}
418
419
const model = buildILPModel(chains);
420
const result = solver.Solve(model);
421
422
// Extract which edges to remove
423
const edgesToRemove: Edge[] = [];
424
model.edges.forEach((edge, idx) => {
425
const edgeId = `edge_${idx}`;
426
if (result[edgeId] === 1) {
427
edgesToRemove.push(edge);
428
}
429
});
430
431
return edgesToRemove;
432
}
433
434
function convertToBreakPoints(
435
edges: Edge[],
436
allPaths: Map<string, string[]>
437
): BreakPoint[] {
438
const breakPointMap = new Map<string, Set<string>>();
439
440
// For each edge to remove, find which cycle files it affects
441
for (const edge of edges) {
442
const edgeKey = `${edge.from}→${edge.to}`;
443
444
if (!breakPointMap.has(edgeKey)) {
445
breakPointMap.set(edgeKey, new Set());
446
}
447
448
// Find all paths containing this edge
449
for (const [cycleFile, path] of allPaths.entries()) {
450
const reversedPath = [...path].reverse();
451
for (let i = 0; i < reversedPath.length - 1; i++) {
452
if (reversedPath[i] === edge.from && reversedPath[i + 1] === edge.to) {
453
breakPointMap.get(edgeKey)!.add(cycleFile);
454
break;
455
}
456
}
457
}
458
}
459
460
// Convert to BreakPoint format
461
const breakPoints: BreakPoint[] = [];
462
for (const [edgeKey, affectedFiles] of breakPointMap.entries()) {
463
const [file, imports] = edgeKey.split("→");
464
breakPoints.push({
465
file,
466
imports,
467
cycleEntry: imports,
468
affectedFiles: Array.from(affectedFiles)
469
});
470
}
471
472
// Sort by number of affected files (descending)
473
breakPoints.sort((a, b) => b.affectedFiles.length - a.affectedFiles.length);
474
475
return breakPoints;
476
}
477
478
function identifyBreakPoints(
479
paths: Map<string, string[]>,
480
filesInCycles: Set<string>
481
): BreakPoint[] {
482
const breakPoints: BreakPoint[] = [];
483
const breakPointMap = new Map<string, Set<string>>(); // edge -> affected cycle files
484
485
for (const [cycleFile, path] of paths.entries()) {
486
if (path.length < 2) continue; // Need at least root -> cycleEntry
487
488
// The break point is the edge from the last non-cycle file to the first cycle file
489
const cycleEntryIndex = path.findIndex((p) => {
490
const normalized = normalizePath(p, filesInCycles);
491
return filesInCycles.has(normalized);
492
});
493
494
if (cycleEntryIndex > 0) {
495
// In the reversed graph, the path shows: root → ... → lastNonCycle → firstCycle → ...
496
// We need to find the LAST file NOT in the cycle (which imports the first cycle file)
497
498
const cycleEntry = path[cycleEntryIndex]; // First file in cycle
499
500
// Find the last non-cycle file by going backwards from cycleEntry
501
let lastNonCycleIndex = cycleEntryIndex - 1;
502
while (lastNonCycleIndex >= 0) {
503
const candidate = path[lastNonCycleIndex];
504
const normalized = normalizePath(candidate, filesInCycles);
505
if (!filesInCycles.has(normalized)) {
506
// Found a non-cycle file!
507
// In reversed graph: ... → lastNonCycle → cycleEntry → ...
508
// In forward terms: cycleEntry imports lastNonCycle
509
// So the break is: in cycleEntry, make import of lastNonCycle dynamic
510
const edge = `${cycleEntry}→${candidate}`;
511
512
if (!breakPointMap.has(edge)) {
513
breakPointMap.set(edge, new Set());
514
}
515
breakPointMap.get(edge)!.add(cycleFile);
516
break;
517
}
518
lastNonCycleIndex--;
519
}
520
}
521
}
522
523
// Convert to BreakPoint objects
524
for (const [edge, affectedFiles] of breakPointMap.entries()) {
525
const [file, imports] = edge.split("→");
526
const cycleEntry = imports;
527
breakPoints.push({
528
file,
529
imports,
530
cycleEntry,
531
affectedFiles: Array.from(affectedFiles),
532
});
533
}
534
535
// Sort by number of affected files (descending)
536
breakPoints.sort((a, b) => b.affectedFiles.length - a.affectedFiles.length);
537
538
return breakPoints;
539
}
540
541
function findCycles(
542
graph: Map<string, Set<string>>,
543
maxCycles: number = 1000
544
): string[][] {
545
const cycles: string[][] = [];
546
const visited = new Set<string>();
547
const recStack = new Set<string>();
548
const path: string[] = [];
549
550
function dfs(node: string): boolean {
551
if (cycles.length >= maxCycles) return true;
552
553
visited.add(node);
554
recStack.add(node);
555
path.push(node);
556
557
const neighbors = graph.get(node) || new Set();
558
for (const neighbor of neighbors) {
559
if (cycles.length >= maxCycles) return true;
560
561
if (!visited.has(neighbor)) {
562
if (dfs(neighbor)) return true;
563
} else if (recStack.has(neighbor)) {
564
// Found a cycle!
565
const cycleStart = path.indexOf(neighbor);
566
if (cycleStart !== -1) {
567
const cycle = path.slice(cycleStart);
568
cycle.push(neighbor); // Complete the cycle
569
cycles.push(cycle);
570
}
571
}
572
}
573
574
path.pop();
575
recStack.delete(node);
576
return false;
577
}
578
579
// Try DFS from each node
580
for (const node of graph.keys()) {
581
if (cycles.length >= maxCycles) break;
582
if (!visited.has(node)) {
583
dfs(node);
584
}
585
}
586
587
return cycles;
588
}
589
590
function buildMFASModel(
591
graph: Map<string, Set<string>>,
592
cycles: string[][]
593
) {
594
// Extract all edges from graph
595
const edgeSet = new Set<string>();
596
const edges: Edge[] = [];
597
598
for (const [from, toSet] of graph.entries()) {
599
for (const to of toSet) {
600
const edgeKey = `${from}→${to}`;
601
if (!edgeSet.has(edgeKey)) {
602
edgeSet.add(edgeKey);
603
edges.push({ from, to });
604
}
605
}
606
}
607
608
// Build ILP model (same structure as Set Cover)
609
const constraints: Record<string, { min: number }> = {};
610
const variables: Record<string, any> = {};
611
const ints: Record<string, 1> = {};
612
613
// One constraint per cycle: at least one edge must be removed
614
cycles.forEach((cycle, cycleIdx) => {
615
constraints[`cycle_${cycleIdx}`] = { min: 1 };
616
});
617
618
// One variable per edge
619
edges.forEach((edge, edgeIdx) => {
620
const edgeId = `edge_${edgeIdx}`;
621
622
variables[edgeId] = {
623
cost: 1, // Minimize number of edges removed
624
};
625
626
// Mark which cycles contain this edge
627
cycles.forEach((cycle, cycleIdx) => {
628
// Check if edge is in this cycle
629
for (let i = 0; i < cycle.length - 1; i++) {
630
if (cycle[i] === edge.from && cycle[i + 1] === edge.to) {
631
variables[edgeId][`cycle_${cycleIdx}`] = 1;
632
break;
633
}
634
}
635
});
636
637
// Force binary (0 or 1)
638
ints[edgeId] = 1;
639
});
640
641
return {
642
optimize: "cost",
643
opType: "min",
644
constraints,
645
variables,
646
ints,
647
edges
648
};
649
}
650
651
function solveMFAS(
652
graph: Map<string, Set<string>>,
653
asyncInCycles: AsyncModule[]
654
): Edge[] {
655
if (graph.size === 0 || asyncInCycles.length === 0) {
656
return [];
657
}
658
659
// Build subgraph: async modules in cycles + their immediate neighbors
660
const asyncPaths = new Set<string>();
661
for (const { path } of asyncInCycles) {
662
asyncPaths.add(simplifyPath(path));
663
}
664
665
const subgraph = new Map<string, Set<string>>();
666
667
// Add all async module nodes and their edges
668
for (const asyncPath of asyncPaths) {
669
if (graph.has(asyncPath)) {
670
subgraph.set(asyncPath, new Set(graph.get(asyncPath)!));
671
}
672
}
673
674
// Add edges from any node to async modules (immediate neighbors)
675
for (const [from, toSet] of graph.entries()) {
676
for (const to of toSet) {
677
if (asyncPaths.has(to)) {
678
if (!subgraph.has(from)) {
679
subgraph.set(from, new Set());
680
}
681
subgraph.get(from)!.add(to);
682
}
683
}
684
}
685
686
console.log(`Built subgraph: ${asyncPaths.size} async modules + ${subgraph.size - asyncPaths.size} neighbors`);
687
688
// Find cycles in the subgraph
689
const maxCycles = 1000;
690
console.log("Finding cycles in subgraph...");
691
const allCycles = findCycles(subgraph, maxCycles);
692
693
// Filter to only cycles that contain at least one async module
694
const cycles = allCycles.filter(cycle =>
695
cycle.some(node => asyncPaths.has(node))
696
);
697
698
if (cycles.length === 0) {
699
return [];
700
}
701
702
console.log(`✓ Found ${cycles.length} cycle(s) containing async modules`);
703
if (cycles.length < allCycles.length) {
704
console.log(` (filtered from ${allCycles.length} total cycles in subgraph)`);
705
}
706
707
// Warn if we hit the cycle limit
708
if (allCycles.length >= maxCycles) {
709
console.log(`⚠️ Warning: Reached maximum cycle limit (${maxCycles})`);
710
console.log(` Solution may not be globally optimal - only considering ${maxCycles} cycles\n`);
711
}
712
713
// Build and solve ILP model on the subgraph
714
const model = buildMFASModel(subgraph, cycles);
715
const result = solver.Solve(model);
716
717
if (!result || !result.feasible) {
718
console.log("⚠️ MFAS solver could not find a solution");
719
return [];
720
}
721
722
// Extract which edges to remove
723
const edgesToRemove: Edge[] = [];
724
model.edges.forEach((edge, idx) => {
725
const edgeId = `edge_${idx}`;
726
if (result[edgeId] === 1) {
727
edgesToRemove.push(edge);
728
}
729
});
730
731
return edgesToRemove;
732
}
733
734
function formatMFASRecommendations(edges: Edge[]): string {
735
if (edges.length === 0) {
736
return "✅ No edges need to be removed (graph is already acyclic)\n";
737
}
738
739
const lines: string[] = [];
740
lines.push("=== ALTERNATIVE: BREAK CYCLES DIRECTLY ===\n");
741
lines.push(`Instead of breaking async propagation chains, you could break`);
742
lines.push(`the cycles themselves by making ${edges.length} import(s) dynamic:\n`);
743
744
for (let i = 0; i < edges.length; i++) {
745
const edge = edges[i];
746
lines.push(`${i + 1}. File: ${simplifyPath(edge.from)}`);
747
lines.push(` Currently imports: ${simplifyPath(edge.to)}`);
748
lines.push(` 💡 Make this import dynamic to help break cycles\n`);
749
}
750
751
lines.push("This represents the minimum feedback arc set (MFAS) -");
752
lines.push("the minimum number of edges to remove to make the graph acyclic.\n");
753
754
return lines.join("\n");
755
}
756
757
function formatChainRecommendations(
758
breakPoints: BreakPoint[],
759
rootModules: AsyncModule[]
760
): string {
761
if (breakPoints.length === 0) {
762
return "✅ No actionable break points identified.\n This may mean the async chains have already been broken.\n";
763
}
764
765
const lines: string[] = [];
766
lines.push("=== IMPORT CHAIN ANALYSIS ===\n");
767
768
if (rootModules.length > 0) {
769
lines.push("Root async modules (files with actual top-level await):");
770
for (const { path } of rootModules.slice(0, 3)) {
771
lines.push(` • ${simplifyPath(path)}`);
772
}
773
lines.push("");
774
}
775
776
lines.push(`Found ${breakPoints.length} recommended break point(s):\n`);
777
778
for (let i = 0; i < breakPoints.length; i++) {
779
const bp = breakPoints[i];
780
lines.push(`${i + 1}. Break point (affects ${bp.affectedFiles.length} cyclic file(s)):\n`);
781
lines.push(` File: ${simplifyPath(bp.file)}`);
782
lines.push(` Currently imports: ${simplifyPath(bp.imports)}`);
783
lines.push(` Cycle entry: ${simplifyPath(bp.cycleEntry)}\n`);
784
lines.push(" 💡 Recommendation:");
785
lines.push(" Make this import dynamic to break the async propagation chain");
786
lines.push(" before it reaches the cyclic code.\n");
787
lines.push(` Affected cyclic files (${bp.affectedFiles.length}):`);
788
for (const file of bp.affectedFiles.slice(0, 5)) {
789
lines.push(` • ${file}`);
790
}
791
if (bp.affectedFiles.length > 5) {
792
lines.push(` ... and ${bp.affectedFiles.length - 5} more`);
793
}
794
lines.push("");
795
}
796
797
return lines.join("\n");
798
}
799
800
if (import.meta.main) {
801
console.log("=== Bundle Async-Cycles Detector ===\n");
802
803
// Check for bundle
804
const bundlePath = "package/pkg-working/bin/quarto.js";
805
if (!existsSync(bundlePath)) {
806
console.error("❌ Bundle not found at:", bundlePath);
807
console.error("\nPlease run prepare-dist first:");
808
console.error(" cd package && ./scripts/common/prepare-dist.sh");
809
console.error("\nSee ~/bin/try-dist for more details.");
810
Deno.exit(1);
811
}
812
813
console.log("✓ Found bundle at:", bundlePath);
814
815
// Read the bundle
816
const bundleCode = Deno.readTextFileSync(bundlePath);
817
console.log(`✓ Bundle size: ${(bundleCode.length / 1024 / 1024).toFixed(1)} MB\n`);
818
819
// Find all modules and async modules
820
console.log("Analyzing modules in bundle...");
821
const allModules = findAllModules(bundleCode);
822
const asyncModules = findAsyncModules(bundleCode);
823
console.log(`✓ Found ${allModules.length} total modules (${asyncModules.length} async)\n`);
824
825
if (asyncModules.length === 0) {
826
console.log("✅ No async modules found. Bundle is clean!");
827
Deno.exit(0);
828
}
829
830
// Find root async modules
831
const rootModules = findRootAsyncModules(bundleCode, asyncModules);
832
833
console.log("=== ROOT ASYNC MODULES ===");
834
console.log("(Modules with actual top-level await)\n");
835
836
if (rootModules.length === 0) {
837
console.log("⚠️ Could not identify root modules (may be in a cycle)");
838
} else {
839
console.log(`Found ${rootModules.length} root async modules:`);
840
for (const { name, path } of rootModules) {
841
console.log(` ${simplifyPath(path)}`);
842
}
843
}
844
console.log();
845
846
// Generate cycles
847
const entryPoint = Deno.args[0] || "src/quarto.ts";
848
const cyclesFile = await generateCycles(entryPoint);
849
console.log(`✓ Generated cycles data\n`);
850
851
// Parse cycles
852
const filesInCycles = parseCyclesFile(cyclesFile);
853
console.log(`✓ Found ${filesInCycles.size} files in cycles\n`);
854
855
// Find intersection: async modules that are in cycles
856
const asyncInCycles: AsyncModule[] = [];
857
const asyncPaths = new Set<string>();
858
859
for (const { name, path } of asyncModules) {
860
asyncPaths.add(path);
861
862
// Check if this path or simplified version is in cycles
863
const simplified = simplifyPath(path);
864
if (filesInCycles.has(path) || filesInCycles.has(simplified)) {
865
asyncInCycles.push({ name, path });
866
}
867
}
868
869
// Results
870
console.log("=== ASYNC MODULES IN CYCLES ===");
871
872
if (asyncInCycles.length === 0) {
873
console.log("✅ No async modules found in cycles!");
874
console.log(" The bundle should not have async initialization issues.\n");
875
} else {
876
console.log("⚠️ Found async modules in import cycles:");
877
console.log(" These are POTENTIALLY problematic - they cause build failures if they have");
878
console.log(" cycles among themselves. The MFAS analysis below will check for this.\n");
879
880
for (const { name, path } of asyncInCycles) {
881
console.log(` ${name.padEnd(30)} ${simplifyPath(path)}`);
882
}
883
console.log();
884
}
885
886
// Build complete dependency graph from bundle (shows ALL init_* dependencies)
887
console.log("Building dependency graph from bundle...");
888
const fullGraph = buildAsyncPropagationGraphFromAST(bundleCode, asyncModules, allModules);
889
console.log(`✓ Built dependency graph (${fullGraph.size} modules)`);
890
891
// Reverse the graph to trace async propagation (A imports B → B imported by A)
892
console.log("Reversing graph to trace async propagation...");
893
const reverseFullGraph = reverseGraph(fullGraph);
894
console.log(`✓ Built reverse graph (${reverseFullGraph.size} modules)\n`);
895
896
// For each root async module, trace BACKWARDS through importers to find cyclic files
897
if (rootModules.length > 0 && asyncInCycles.length > 0) {
898
console.log("=== TRACING ASYNC PROPAGATION CHAINS ===\n");
899
900
const allPaths = new Map<string, string[]>();
901
902
for (const { path: rootPath } of rootModules) {
903
const paths = tracePaths(reverseFullGraph, simplifyPath(rootPath), filesInCycles);
904
905
for (const [cycleFile, path] of paths.entries()) {
906
// Keep the shortest path to each cycle file
907
if (!allPaths.has(cycleFile) || path.length < allPaths.get(cycleFile)!.length) {
908
allPaths.set(cycleFile, path);
909
}
910
}
911
}
912
913
console.log(`Found ${allPaths.size} paths from root async modules to cyclic files\n`);
914
915
// Add ILP optimization step
916
console.log("=== OPTIMIZING BREAK POINTS WITH ILP ===\n");
917
const reversedChains = reverseChains(allPaths);
918
console.log(`Solving for minimum edge cut across ${reversedChains.length} chains...`);
919
920
const optimalEdges = solveMinimumEdgeCut(reversedChains);
921
console.log(`✓ Optimal solution: ${optimalEdges.length} edge(s) to remove\n`);
922
923
// Convert ILP solution to break points
924
const breakPoints = convertToBreakPoints(optimalEdges, allPaths);
925
926
// Format and display recommendations
927
const recommendations = formatChainRecommendations(breakPoints, rootModules);
928
console.log(recommendations);
929
930
// Add MFAS alternative analysis
931
console.log("\n=== ALTERNATIVE APPROACH: MINIMUM FEEDBACK ARC SET ===\n");
932
console.log("Analyzing cycles containing async modules...");
933
934
const mfasEdges = solveMFAS(fullGraph, asyncInCycles);
935
936
if (mfasEdges.length > 0) {
937
console.log(`✓ Minimum feedback arc set: ${mfasEdges.length} edge(s)\n`);
938
const mfasRecommendations = formatMFASRecommendations(mfasEdges);
939
console.log(mfasRecommendations);
940
console.log("💡 TIP: If you cannot fix all recommended edges at once:");
941
console.log(" 1. Fix some of the recommended dynamic imports");
942
console.log(" 2. Rebuild the bundle");
943
console.log(" 3. Run this tool again - the recommendations may change!");
944
console.log(" Breaking some cycles can eliminate others, reducing the total work needed.\n");
945
} else {
946
console.log("✅ No cycles found entirely among async modules!");
947
console.log(" The async modules are in cycles with non-async code, which is probably fine.");
948
console.log(" Build should succeed without issues.\n");
949
}
950
} else if (asyncInCycles.length === 0) {
951
console.log("✅ No async modules in cycles - no chain analysis needed.\n");
952
} else {
953
console.log("⚠️ Could not trace chains (no root async modules identified)\n");
954
}
955
956
// Cleanup
957
try {
958
Deno.removeSync(cyclesFile);
959
} catch {
960
// Ignore cleanup errors
961
}
962
}
963
964