Skip to content
File

Blob: .agents/skills/typescript-import-barrels/scripts/refactor-imports.mjs

javascript1214 lines
1#!/usr/bin/env node
2 
3import fs from "node:fs/promises";
4import path from "node:path";
5import process from "node:process";
6import prettier from "prettier";
7import ts from "typescript";
8 
9const PLAN_VERSION = 1;
10const INDEX_BASENAMES = new Set(["index.ts", "index.tsx"]);
11 
12const main = async () => {
13 const args = parseArgs(process.argv.slice(2));
14 const projectRoot = process.cwd();
15 
16 if (args.command === "analyze") {
17 const analysis = analyzeProject({
18 projectRoot,
19 rootDir: args.options.root ?? "src",
20 });
21 await writePlanIfRequested(analysis.plan, args.options.plan);
22 process.stdout.write(`${renderAnalysisSummary(analysis)}\n`);
23 return;
24 }
25 
26 if (args.command === "apply") {
27 if (!args.options.plan) {
28 throw new Error("`apply` requires `--plan <file>`.");
29 }
30 
31 const plan = JSON.parse(await fs.readFile(args.options.plan, "utf8"));
32 validatePlan(plan);
33 
34 const applyResult = await applyPlan({
35 projectRoot,
36 plan,
37 });
38 
39 process.stdout.write(`${renderApplySummary(applyResult)}\n`);
40 return;
41 }
42 
43 throw new Error(`Unknown command: ${args.command}`);
44};
45 
46const parseArgs = (argv) => {
47 const [command, ...rest] = argv;
48 if (!command) {
49 throw new Error("Usage: refactor-imports.mjs <analyze|apply> [--root src] [--plan file]");
50 }
51 
52 const options = {};
53 for (let index = 0; index < rest.length; index += 1) {
54 const token = rest[index];
55 if (token === "--root" || token === "--plan") {
56 const value = rest[index + 1];
57 if (!value) {
58 throw new Error(`Missing value for ${token}.`);
59 }
60 options[token.slice(2)] = value;
61 index += 1;
62 continue;
63 }
64 
65 throw new Error(`Unknown argument: ${token}`);
66 }
67 
68 return { command, options };
69};
70 
71const validatePlan = (plan) => {
72 if (!plan || typeof plan !== "object") {
73 throw new Error("Invalid plan JSON.");
74 }
75 if (plan.version !== PLAN_VERSION) {
76 throw new Error(`Unsupported plan version: ${plan.version}`);
77 }
78 if (!Array.isArray(plan.barrelChanges) || !Array.isArray(plan.fileChanges)) {
79 throw new Error("Plan is missing required arrays.");
80 }
81};
82 
83const writePlanIfRequested = async (plan, planPath) => {
84 if (!planPath) {
85 return;
86 }
87 await fs.writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
88};
89 
90const analyzeProject = ({ projectRoot, rootDir }) => {
91 const env = createProjectEnvironment({ projectRoot, rootDir });
92 const candidates = collectCollapseCandidates(env);
93 const duplicateMergeFiles = collectDuplicateMergeFiles(env);
94 
95 const selectedBarrels = new Map();
96 const selectedFiles = new Map();
97 const blocked = [];
98 
99 const sortedCandidates = [...candidates].sort(compareCandidates);
100 for (const candidate of sortedCandidates) {
101 const decision = trySelectCandidate({
102 env,
103 selectedBarrels,
104 selectedFiles,
105 candidate,
106 });
107 
108 if (!decision.accepted) {
109 blocked.push({
110 type: "collapse",
111 file: candidate.consumerRel,
112 directory: candidate.dirRel,
113 barrelFile: candidate.barrelRel,
114 reason: decision.reason,
115 });
116 }
117 }
118 
119 for (const [file, specifiers] of duplicateMergeFiles) {
120 const fileState = getOrCreateFileState(selectedFiles, file);
121 fileState.mergeDuplicateSpecifiers = [...new Set([...fileState.mergeDuplicateSpecifiers, ...specifiers])].sort();
122 }
123 
124 const barrelChanges = [...selectedBarrels.values()]
125 .filter((state) => state.modulesToAdd.size > 0)
126 .map((state) => ({
127 barrelFile: state.barrelRel,
128 action: state.barrelExists ? "extend" : "create",
129 modulesToExport: [...state.finalExportModules].sort(),
130 modulesToAdd: [...state.modulesToAdd].sort(),
131 consumers: [...state.rewrites.keys()].sort(),
132 }))
133 .sort(compareByBarrelFile);
134 
135 const fileChanges = [...selectedFiles.values()]
136 .map((fileState) => ({
137 file: fileState.file,
138 collapseGroups: [...fileState.barrels.entries()]
139 .map(([barrelRel, barrelData]) => ({
140 barrelFile: barrelRel,
141 barrelSpecifier: barrelData.barrelSpecifier,
142 sourceModules: [...barrelData.sourceModules].sort(),
143 }))
144 .sort((left, right) => left.barrelFile.localeCompare(right.barrelFile)),
145 mergeDuplicateSpecifiers: [...new Set(fileState.mergeDuplicateSpecifiers)].sort(),
146 }))
147 .sort((left, right) => left.file.localeCompare(right.file));
148 
149 fileChanges.sort((left, right) => left.file.localeCompare(right.file));
150 
151 const plan = {
152 version: PLAN_VERSION,
153 generatedAt: new Date().toISOString(),
154 rootDir,
155 tsconfig: path.basename(env.tsconfigPath),
156 summary: {
157 fileCount: env.fileInfos.size,
158 edgeCount: countEdges(env.baseGraph),
159 cycleCount: env.baseCycleCount,
160 candidateCount: candidates.length,
161 barrelChangeCount: barrelChanges.length,
162 fileChangeCount: fileChanges.length,
163 blockedCount: blocked.length,
164 },
165 barrelChanges,
166 fileChanges,
167 blocked,
168 };
169 
170 return {
171 env,
172 plan,
173 };
174};
175 
176const compareCandidates = (left, right) => {
177 if (left.barrelExists !== right.barrelExists) {
178 return left.barrelExists ? -1 : 1;
179 }
180 if (left.moduleRels.length !== right.moduleRels.length) {
181 return right.moduleRels.length - left.moduleRels.length;
182 }
183 return left.consumerRel.localeCompare(right.consumerRel) || left.dirRel.localeCompare(right.dirRel);
184};
185 
186const compareByBarrelFile = (left, right) => left.barrelFile.localeCompare(right.barrelFile);
187 
188const collectCollapseCandidates = (env) => {
189 const candidates = [];
190 
191 for (const [consumerRel, fileInfo] of env.fileInfos) {
192 const grouped = new Map();
193 
194 for (const importRecord of fileInfo.imports) {
195 if (importRecord.kind !== "named" || !importRecord.resolvedRel) {
196 continue;
197 }
198 
199 if (INDEX_BASENAMES.has(path.posix.basename(importRecord.resolvedRel))) {
200 continue;
201 }
202 
203 const dirRel = path.posix.dirname(importRecord.resolvedRel);
204 if (dirRel === fileInfo.dirRel) {
205 continue;
206 }
207 
208 const barrelRel = getBarrelPath(env, dirRel);
209 const barrelSpecifier = getSafeBarrelSpecifier({
210 env,
211 consumerRel,
212 dirRel,
213 barrelRel,
214 originalSpecifier: importRecord.specifier,
215 });
216 if (!barrelSpecifier) {
217 continue;
218 }
219 
220 if (!grouped.has(dirRel)) {
221 grouped.set(dirRel, {
222 dirRel,
223 barrelRel,
224 barrelExists: env.barrelInfos.has(barrelRel),
225 barrelSpecifier,
226 moduleToRecords: new Map(),
227 });
228 }
229 
230 const group = grouped.get(dirRel);
231 if (group.barrelSpecifier !== barrelSpecifier) {
232 group.barrelSpecifier = null;
233 }
234 
235 if (!group.moduleToRecords.has(importRecord.resolvedRel)) {
236 group.moduleToRecords.set(importRecord.resolvedRel, []);
237 }
238 group.moduleToRecords.get(importRecord.resolvedRel).push(importRecord);
239 }
240 
241 for (const group of grouped.values()) {
242 if (!group.barrelSpecifier) {
243 continue;
244 }
245 if (group.moduleToRecords.size < 2) {
246 continue;
247 }
248 
249 const moduleRels = [...group.moduleToRecords.keys()].sort();
250 const requestedImports = [];
251 const seenImportedNames = new Map();
252 let conflict = null;
253 
254 for (const [moduleRel, records] of group.moduleToRecords) {
255 for (const record of records) {
256 for (const binding of record.bindings) {
257 const previousModule = seenImportedNames.get(binding.importedName);
258 if (previousModule && previousModule !== moduleRel) {
259 conflict = `imported name \`${binding.importedName}\` comes from multiple modules in ${group.dirRel}`;
260 break;
261 }
262 seenImportedNames.set(binding.importedName, moduleRel);
263 requestedImports.push({
264 fromModule: moduleRel,
265 importedName: binding.importedName,
266 localName: binding.localName,
267 isTypeOnly: binding.isTypeOnly,
268 });
269 }
270 if (conflict) {
271 break;
272 }
273 }
274 if (conflict) {
275 break;
276 }
277 }
278 
279 if (conflict) {
280 continue;
281 }
282 
283 candidates.push({
284 consumerRel,
285 dirRel: group.dirRel,
286 barrelRel: group.barrelRel,
287 barrelExists: group.barrelExists,
288 barrelSpecifier: group.barrelSpecifier,
289 moduleRels,
290 requestedImports,
291 });
292 }
293 }
294 
295 return candidates;
296};
297 
298const collectDuplicateMergeFiles = (env) => {
299 const result = new Map();
300 
301 for (const [fileRel, fileInfo] of env.fileInfos) {
302 const counts = new Map();
303 for (const importRecord of fileInfo.imports) {
304 if (importRecord.kind !== "named") {
305 continue;
306 }
307 counts.set(importRecord.specifier, (counts.get(importRecord.specifier) ?? 0) + 1);
308 }
309 
310 const duplicates = [...counts.entries()]
311 .filter(([, count]) => count > 1)
312 .map(([specifier]) => specifier)
313 .sort();
314 
315 if (duplicates.length > 0) {
316 result.set(fileRel, duplicates);
317 }
318 }
319 
320 return result;
321};
322 
323const trySelectCandidate = ({ env, selectedBarrels, selectedFiles, candidate }) => {
324 if (
325 !barrelSpecifierTargetsExpectedFile({
326 env,
327 consumerRel: candidate.consumerRel,
328 barrelRel: candidate.barrelRel,
329 specifier: candidate.barrelSpecifier,
330 })
331 ) {
332 const consumerFile = env.fileInfos.get(candidate.consumerRel);
333 const collapsedResolution = resolveLocalModule({
334 compilerOptions: env.compilerOptions,
335 importerFile: consumerFile.sourceFile.fileName,
336 specifier: candidate.barrelSpecifier,
337 projectRoot: env.projectRoot,
338 });
339 return {
340 accepted: false,
341 reason: `collapsed specifier \`${candidate.barrelSpecifier}\` resolves to ${collapsedResolution ?? "a non-local module"} instead of ${candidate.barrelRel}`,
342 };
343 }
344 
345 const barrelInfo = env.barrelInfos.get(candidate.barrelRel) ?? createSyntheticBarrelInfo(candidate.barrelRel);
346 const state = getOrCreateBarrelState(selectedBarrels, barrelInfo);
347 
348 if (candidate.barrelExists && state.modificationBlocked) {
349 const allCovered = candidate.moduleRels.every((moduleRel) => state.finalExportModules.has(moduleRel));
350 if (!allCovered) {
351 return {
352 accepted: false,
353 reason: state.modificationBlocked,
354 };
355 }
356 }
357 
358 const newModules = candidate.moduleRels.filter((moduleRel) => !state.finalExportModules.has(moduleRel));
359 const collisionReason = getCollisionReason({
360 env,
361 state,
362 newModules,
363 });
364 if (collisionReason) {
365 return {
366 accepted: false,
367 reason: collisionReason,
368 };
369 }
370 
371 const missingExports = getMissingRequestedExports({
372 env,
373 state,
374 candidate,
375 newModules,
376 });
377 if (missingExports.length > 0) {
378 return {
379 accepted: false,
380 reason: `barrel would not expose: ${missingExports.map((name) => `\`${name}\``).join(", ")}`,
381 };
382 }
383 
384 if (
385 !isCandidateAcyclic({
386 env,
387 selectedBarrels,
388 candidate,
389 newModules,
390 })
391 ) {
392 return {
393 accepted: false,
394 reason: "simulated rewrite introduces a cycle",
395 };
396 }
397 
398 for (const moduleRel of newModules) {
399 state.modulesToAdd.add(moduleRel);
400 state.finalExportModules.add(moduleRel);
401 addToSet(state.selectedExportNames, env.moduleExports.get(moduleRel)?.names ?? []);
402 }
403 
404 if (!state.rewrites.has(candidate.consumerRel)) {
405 state.rewrites.set(candidate.consumerRel, new Set());
406 }
407 addToSet(state.rewrites.get(candidate.consumerRel), candidate.moduleRels);
408 
409 const fileState = getOrCreateFileState(selectedFiles, candidate.consumerRel);
410 if (!fileState.barrels.has(candidate.barrelRel)) {
411 fileState.barrels.set(candidate.barrelRel, {
412 barrelSpecifier: candidate.barrelSpecifier,
413 sourceModules: new Set(),
414 });
415 }
416 addToSet(fileState.barrels.get(candidate.barrelRel).sourceModules, candidate.moduleRels);
417 
418 return { accepted: true };
419};
420 
421const createSyntheticBarrelInfo = (barrelRel) => ({
422 fileRel: barrelRel,
423 exists: false,
424 supportedForRewrite: true,
425 unsupportedReason: null,
426 exportedModules: new Set(),
427 exportedNames: new Set(),
428});
429 
430const getOrCreateBarrelState = (selectedBarrels, barrelInfo) => {
431 if (selectedBarrels.has(barrelInfo.fileRel)) {
432 return selectedBarrels.get(barrelInfo.fileRel);
433 }
434 
435 const state = {
436 barrelRel: barrelInfo.fileRel,
437 barrelExists: barrelInfo.exists,
438 modificationBlocked: barrelInfo.supportedForRewrite ? null : barrelInfo.unsupportedReason,
439 existingExportedModules: new Set(barrelInfo.exportedModules),
440 finalExportModules: new Set(barrelInfo.exportedModules),
441 existingExportNames: new Set(barrelInfo.exportedNames),
442 selectedExportNames: new Set(),
443 modulesToAdd: new Set(),
444 rewrites: new Map(),
445 };
446 selectedBarrels.set(barrelInfo.fileRel, state);
447 return state;
448};
449 
450const getOrCreateFileState = (selectedFiles, file) => {
451 if (selectedFiles.has(file)) {
452 return selectedFiles.get(file);
453 }
454 const state = {
455 file,
456 barrels: new Map(),
457 mergeDuplicateSpecifiers: [],
458 };
459 selectedFiles.set(file, state);
460 return state;
461};
462 
463const getCollisionReason = ({ env, state, newModules }) => {
464 const seenNames = new Set(state.existingExportNames);
465 addToSet(seenNames, state.selectedExportNames);
466 
467 for (const moduleRel of newModules) {
468 const moduleExportInfo = env.moduleExports.get(moduleRel);
469 if (!moduleExportInfo) {
470 return `could not inspect exports for ${moduleRel}`;
471 }
472 
473 for (const exportName of moduleExportInfo.names) {
474 if (seenNames.has(exportName)) {
475 return `export name collision on \`${exportName}\` in ${state.barrelRel}`;
476 }
477 }
478 
479 addToSet(seenNames, moduleExportInfo.names);
480 }
481 
482 return null;
483};
484 
485const getMissingRequestedExports = ({ env, state, candidate, newModules }) => {
486 const available = new Set(state.existingExportNames);
487 addToSet(available, state.selectedExportNames);
488 for (const moduleRel of newModules) {
489 addToSet(available, env.moduleExports.get(moduleRel)?.names ?? []);
490 }
491 
492 const missing = new Set();
493 for (const request of candidate.requestedImports) {
494 if (!available.has(request.importedName)) {
495 missing.add(request.importedName);
496 }
497 }
498 
499 return [...missing].sort();
500};
501 
502const isCandidateAcyclic = ({ env, selectedBarrels, candidate, newModules }) => {
503 const simulation = cloneGraph(env.baseGraph);
504 
505 for (const state of selectedBarrels.values()) {
506 applyBarrelStateToGraph(simulation, state);
507 }
508 
509 const barrelState = selectedBarrels.get(candidate.barrelRel);
510 const tentativeState = {
511 barrelRel: candidate.barrelRel,
512 barrelExists: candidate.barrelExists,
513 modulesToAdd: new Set(barrelState?.modulesToAdd ?? []),
514 rewrites: cloneRewriteMap(barrelState?.rewrites),
515 };
516 addToSet(tentativeState.modulesToAdd, newModules);
517 if (!tentativeState.rewrites.has(candidate.consumerRel)) {
518 tentativeState.rewrites.set(candidate.consumerRel, new Set());
519 }
520 addToSet(tentativeState.rewrites.get(candidate.consumerRel), candidate.moduleRels);
521 applyBarrelStateToGraph(simulation, tentativeState);
522 
523 return countCycles(simulation) === 0;
524};
525 
526const applyBarrelStateToGraph = (graph, state) => {
527 ensureGraphNode(graph, state.barrelRel);
528 const barrelDeps = graph.get(state.barrelRel);
529 addToSet(barrelDeps, state.modulesToAdd);
530 
531 for (const [consumerRel, moduleRels] of state.rewrites.entries()) {
532 ensureGraphNode(graph, consumerRel);
533 const deps = graph.get(consumerRel);
534 for (const moduleRel of moduleRels) {
535 deps.delete(moduleRel);
536 }
537 deps.add(state.barrelRel);
538 }
539};
540 
541const cloneRewriteMap = (rewrites) => {
542 const clone = new Map();
543 if (!rewrites) {
544 return clone;
545 }
546 for (const [file, modules] of rewrites.entries()) {
547 clone.set(file, new Set(modules));
548 }
549 return clone;
550};
551 
552const applyPlan = async ({ projectRoot, plan }) => {
553 const env = createProjectEnvironment({
554 projectRoot,
555 rootDir: plan.rootDir ?? "src",
556 });
557 
558 const touchedFiles = new Set();
559 
560 for (const barrelChange of plan.barrelChanges) {
561 const barrelText = renderBarrelFile(barrelChange.modulesToExport);
562 await writeFormattedText({
563 projectRoot,
564 fileRel: barrelChange.barrelFile,
565 text: barrelText,
566 });
567 touchedFiles.add(barrelChange.barrelFile);
568 }
569 
570 for (const fileChange of plan.fileChanges) {
571 const sourceFileInfo = env.fileInfos.get(fileChange.file);
572 if (!sourceFileInfo) {
573 throw new Error(`Plan references missing file: ${fileChange.file}`);
574 }
575 
576 const updatedText = buildUpdatedSourceText({
577 env,
578 fileInfo: sourceFileInfo,
579 fileChange,
580 });
581 if (updatedText === sourceFileInfo.text) {
582 continue;
583 }
584 
585 await writeFormattedText({
586 projectRoot,
587 fileRel: fileChange.file,
588 text: updatedText,
589 });
590 touchedFiles.add(fileChange.file);
591 }
592 
593 const verification = analyzeProject({
594 projectRoot,
595 rootDir: plan.rootDir ?? "src",
596 });
597 
598 return {
599 touchedFiles: [...touchedFiles].sort(),
600 verification: verification.plan.summary,
601 };
602};
603 
604const renderBarrelFile = (moduleRels) =>
605 `${moduleRels
606 .map((moduleRel) => `export * from "${toAliasSpecifier(moduleRel)}";`)
607 .sort()
608 .join("\n")}\n`;
609 
610const buildUpdatedSourceText = ({ env, fileInfo, fileChange }) => {
611 const collapseByModule = new Map();
612 for (const group of fileChange.collapseGroups) {
613 for (const moduleRel of group.sourceModules) {
614 collapseByModule.set(moduleRel, group.barrelSpecifier);
615 }
616 }
617 
618 const buckets = new Map();
619 const removedImportIds = new Set();
620 
621 for (const importRecord of fileInfo.imports) {
622 if (importRecord.kind === "named") {
623 const targetSpecifier = collapseByModule.get(importRecord.resolvedRel) ?? importRecord.specifier;
624 const bucket = getOrCreateImportBucket(buckets, targetSpecifier, importRecord.statementIndex);
625 addBindingsToBucket(bucket, importRecord.bindings);
626 removedImportIds.add(importRecord.id);
627 continue;
628 }
629 
630 if (
631 importRecord.kind !== "side-effect" &&
632 importRecord.resolvedRel &&
633 collapseByModule.has(importRecord.resolvedRel)
634 ) {
635 throw new Error(`Cannot collapse unsupported import in ${fileInfo.fileRel}: ${importRecord.specifier}`);
636 }
637 }
638 
639 const emitIndexMap = new Map();
640 for (const [specifier, bucket] of buckets.entries()) {
641 if (!emitIndexMap.has(bucket.emitAt)) {
642 emitIndexMap.set(bucket.emitAt, []);
643 }
644 emitIndexMap.get(bucket.emitAt).push(specifier);
645 }
646 for (const specifiers of emitIndexMap.values()) {
647 specifiers.sort();
648 }
649 
650 const newStatements = [];
651 for (let index = 0; index < fileInfo.sourceFile.statements.length; index += 1) {
652 if (emitIndexMap.has(index)) {
653 for (const specifier of emitIndexMap.get(index)) {
654 newStatements.push(buildMergedImportDeclaration(specifier, buckets.get(specifier).bindings));
655 }
656 }
657 
658 const statement = fileInfo.sourceFile.statements[index];
659 if (!ts.isImportDeclaration(statement)) {
660 newStatements.push(statement);
661 continue;
662 }
663 
664 const record = fileInfo.importByStatement.get(index);
665 if (record && removedImportIds.has(record.id)) {
666 continue;
667 }
668 
669 newStatements.push(statement);
670 }
671 
672 const updatedSource = ts.factory.updateSourceFile(fileInfo.sourceFile, newStatements);
673 const printer = ts.createPrinter({
674 newLine: ts.NewLineKind.LineFeed,
675 });
676 return printer.printFile(updatedSource);
677};
678 
679const getOrCreateImportBucket = (buckets, specifier, emitAt) => {
680 if (buckets.has(specifier)) {
681 const bucket = buckets.get(specifier);
682 bucket.emitAt = Math.min(bucket.emitAt, emitAt);
683 return bucket;
684 }
685 
686 const bucket = {
687 emitAt,
688 bindings: [],
689 seen: new Map(),
690 };
691 buckets.set(specifier, bucket);
692 return bucket;
693};
694 
695const addBindingsToBucket = (bucket, bindings) => {
696 for (const binding of bindings) {
697 const key = `${binding.importedName}:${binding.localName}`;
698 const existingIndex = bucket.seen.get(key);
699 if (existingIndex === undefined) {
700 bucket.seen.set(key, bucket.bindings.length);
701 bucket.bindings.push({ ...binding });
702 continue;
703 }
704 
705 if (!binding.isTypeOnly) {
706 bucket.bindings[existingIndex].isTypeOnly = false;
707 }
708 }
709};
710 
711const buildMergedImportDeclaration = (specifier, bindings) => {
712 const mergedBindings = [...bindings];
713 const hasValueBinding = mergedBindings.some((binding) => !binding.isTypeOnly);
714 const namedImports = mergedBindings.map((binding) =>
715 ts.factory.createImportSpecifier(
716 binding.isTypeOnly && hasValueBinding,
717 binding.importedName === binding.localName ? undefined : ts.factory.createIdentifier(binding.importedName),
718 ts.factory.createIdentifier(binding.localName),
719 ),
720 );
721 
722 const importClause = ts.factory.createImportClause(
723 !hasValueBinding,
724 undefined,
725 ts.factory.createNamedImports(namedImports),
726 );
727 
728 return ts.factory.createImportDeclaration(
729 undefined,
730 importClause,
731 ts.factory.createStringLiteral(specifier),
732 undefined,
733 );
734};
735 
736const writeFormattedText = async ({ projectRoot, fileRel, text }) => {
737 const absPath = path.join(projectRoot, fileRel);
738 const resolvedConfig = await prettier.resolveConfig(absPath);
739 const formatted = await prettier.format(text, {
740 ...(resolvedConfig ?? {}),
741 filepath: absPath,
742 });
743 await fs.mkdir(path.dirname(absPath), { recursive: true });
744 await fs.writeFile(absPath, formatted, "utf8");
745};
746 
747const createProjectEnvironment = ({ projectRoot, rootDir }) => {
748 const tsconfigPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json");
749 if (!tsconfigPath) {
750 throw new Error("Could not find tsconfig.json.");
751 }
752 
753 const configText = ts.sys.readFile(tsconfigPath);
754 if (!configText) {
755 throw new Error(`Could not read ${tsconfigPath}.`);
756 }
757 
758 const parsedJson = ts.parseConfigFileTextToJson(tsconfigPath, configText);
759 if (parsedJson.error) {
760 throw new Error(formatDiagnostic(parsedJson.error));
761 }
762 
763 const parsedConfig = ts.parseJsonConfigFileContent(parsedJson.config, ts.sys, projectRoot);
764 if (parsedConfig.errors.length > 0) {
765 throw new Error(parsedConfig.errors.map(formatDiagnostic).join("\n"));
766 }
767 
768 const normalizedRootDir = toPosix(rootDir.replace(/^\.?\//, ""));
769 const rootPrefix = `${normalizedRootDir}/`;
770 const sourceFiles = parsedConfig.fileNames.filter((fileName) => {
771 const rel = toPosix(path.relative(projectRoot, fileName));
772 return rel.startsWith(rootPrefix) && /\.(ts|tsx)$/.test(rel) && !rel.endsWith(".d.ts");
773 });
774 
775 const host = ts.createCompilerHost(parsedConfig.options, true);
776 const program = ts.createProgram(sourceFiles, parsedConfig.options, host);
777 const checker = program.getTypeChecker();
778 const fileInfos = new Map();
779 const barrelInfos = new Map();
780 const moduleExports = new Map();
781 const baseGraph = new Map();
782 
783 for (const sourceFile of program.getSourceFiles()) {
784 const fileRel = normalizeSourceFile(projectRoot, sourceFile.fileName);
785 if (!fileRel) {
786 continue;
787 }
788 
789 const fileInfo = analyzeSourceFile({
790 projectRoot,
791 sourceFile,
792 compilerOptions: parsedConfig.options,
793 checker,
794 });
795 fileInfos.set(fileRel, fileInfo);
796 baseGraph.set(fileRel, new Set(fileInfo.dependencies));
797 }
798 
799 for (const [fileRel, fileInfo] of fileInfos) {
800 moduleExports.set(fileRel, inspectModuleExports(checker, fileInfo.sourceFile));
801 
802 if (INDEX_BASENAMES.has(path.posix.basename(fileRel))) {
803 const barrelInfo = inspectBarrelFile({
804 fileInfo,
805 projectRoot,
806 compilerOptions: parsedConfig.options,
807 checker,
808 });
809 barrelInfos.set(fileRel, barrelInfo);
810 }
811 }
812 
813 return {
814 projectRoot,
815 rootDir: normalizedRootDir,
816 tsconfigPath,
817 compilerOptions: parsedConfig.options,
818 checker,
819 fileInfos,
820 baseGraph,
821 baseCycleCount: countCycles(baseGraph),
822 barrelInfos,
823 moduleExports,
824 };
825};
826 
827const normalizeSourceFile = (projectRoot, fileName) => {
828 const rel = toPosix(path.relative(projectRoot, fileName));
829 if (!rel.startsWith("src/") || rel.endsWith(".d.ts")) {
830 return null;
831 }
832 return rel;
833};
834 
835const analyzeSourceFile = ({ projectRoot, sourceFile, compilerOptions, checker }) => {
836 const fileRel = toPosix(path.relative(projectRoot, sourceFile.fileName));
837 const imports = [];
838 const importByStatement = new Map();
839 const dependencies = new Set();
840 
841 sourceFile.statements.forEach((statement, statementIndex) => {
842 const dependency = getDependencyRecord({
843 compilerOptions,
844 fileName: sourceFile.fileName,
845 statement,
846 statementIndex,
847 projectRoot,
848 });
849 
850 if (dependency?.resolvedRel) {
851 dependencies.add(dependency.resolvedRel);
852 }
853 
854 if (ts.isImportDeclaration(statement) && dependency) {
855 imports.push(dependency);
856 importByStatement.set(statementIndex, dependency);
857 }
858 });
859 
860 return {
861 fileRel,
862 dirRel: path.posix.dirname(fileRel),
863 sourceFile,
864 text: sourceFile.getFullText(),
865 imports,
866 importByStatement,
867 dependencies,
868 checker,
869 };
870};
871 
872const getDependencyRecord = ({ compilerOptions, fileName, statement, statementIndex, projectRoot }) => {
873 if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement))) {
874 return null;
875 }
876 
877 if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) {
878 return null;
879 }
880 
881 const specifier = statement.moduleSpecifier.text;
882 const resolvedRel = resolveLocalModule({
883 compilerOptions,
884 importerFile: fileName,
885 specifier,
886 projectRoot,
887 });
888 
889 if (!ts.isImportDeclaration(statement)) {
890 return {
891 kind: "export",
892 specifier,
893 resolvedRel,
894 statementIndex,
895 };
896 }
897 
898 const importClause = statement.importClause;
899 const baseRecord = {
900 id: `${toPosix(path.relative(projectRoot, fileName))}:${statementIndex}`,
901 specifier,
902 resolvedRel,
903 statementIndex,
904 node: statement,
905 };
906 
907 if (!importClause) {
908 return {
909 ...baseRecord,
910 kind: "side-effect",
911 bindings: [],
912 };
913 }
914 
915 if (!importClause.name && importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
916 const bindings = importClause.namedBindings.elements.map((element) => ({
917 importedName: element.propertyName?.text ?? element.name.text,
918 localName: element.name.text,
919 isTypeOnly: importClause.isTypeOnly || element.isTypeOnly,
920 }));
921 return {
922 ...baseRecord,
923 kind: "named",
924 bindings,
925 };
926 }
927 
928 return {
929 ...baseRecord,
930 kind: "unsupported",
931 bindings: [],
932 };
933};
934 
935const resolveLocalModule = ({ compilerOptions, importerFile, specifier, projectRoot }) => {
936 const resolvedModule = ts.resolveModuleName(specifier, importerFile, compilerOptions, ts.sys).resolvedModule;
937 if (!resolvedModule) {
938 return null;
939 }
940 
941 const resolvedRel = toPosix(path.relative(projectRoot, resolvedModule.resolvedFileName));
942 if (!resolvedRel.startsWith("src/") || resolvedRel.endsWith(".d.ts")) {
943 return null;
944 }
945 return resolvedRel;
946};
947 
948const inspectModuleExports = (checker, sourceFile) => {
949 const symbol = checker.getSymbolAtLocation(sourceFile) ?? sourceFile.symbol;
950 const names = new Set();
951 let hasDefault = false;
952 
953 if (!symbol) {
954 return { names, hasDefault };
955 }
956 
957 for (const exportSymbol of checker.getExportsOfModule(symbol)) {
958 const exportName = exportSymbol.getName();
959 if (exportName === "default") {
960 hasDefault = true;
961 continue;
962 }
963 names.add(exportName);
964 }
965 
966 return { names, hasDefault };
967};
968 
969const inspectBarrelFile = ({ fileInfo, projectRoot, compilerOptions, checker }) => {
970 let supportedForRewrite = true;
971 let unsupportedReason = null;
972 const exportedModules = new Set();
973 
974 for (const statement of fileInfo.sourceFile.statements) {
975 if (
976 !ts.isExportDeclaration(statement) ||
977 !statement.moduleSpecifier ||
978 !ts.isStringLiteral(statement.moduleSpecifier)
979 ) {
980 supportedForRewrite = false;
981 unsupportedReason = `existing barrel ${fileInfo.fileRel} is not export-from only`;
982 break;
983 }
984 
985 if (statement.exportClause) {
986 supportedForRewrite = false;
987 unsupportedReason = `existing barrel ${fileInfo.fileRel} uses named export clauses`;
988 break;
989 }
990 
991 const resolvedRel = resolveLocalModule({
992 compilerOptions,
993 importerFile: fileInfo.sourceFile.fileName,
994 specifier: statement.moduleSpecifier.text,
995 projectRoot,
996 });
997 if (!resolvedRel) {
998 supportedForRewrite = false;
999 unsupportedReason = `existing barrel ${fileInfo.fileRel} re-exports non-local modules`;
1000 break;
1001 }
1002 exportedModules.add(resolvedRel);
1003 }
1004 
1005 return {
1006 fileRel: fileInfo.fileRel,
1007 exists: true,
1008 supportedForRewrite,
1009 unsupportedReason,
1010 exportedModules,
1011 exportedNames: inspectModuleExports(checker, fileInfo.sourceFile).names,
1012 };
1013};
1014 
1015const getBarrelPath = (env, dirRel) => {
1016 const tsPath = `${dirRel}/index.ts`;
1017 if (env.barrelInfos.has(tsPath) || env.fileInfos.has(tsPath)) {
1018 return tsPath;
1019 }
1020 const tsxPath = `${dirRel}/index.tsx`;
1021 if (env.barrelInfos.has(tsxPath) || env.fileInfos.has(tsxPath)) {
1022 return tsxPath;
1023 }
1024 return tsPath;
1025};
1026 
1027const getSafeBarrelSpecifier = ({ env, consumerRel, dirRel, barrelRel, originalSpecifier }) => {
1028 if (originalSpecifier.startsWith("@/")) {
1029 if (!hasShadowingSiblingSourceFile(env, dirRel)) {
1030 return toAliasSpecifier(dirRel);
1031 }
1032 return toAliasSpecifier(withoutTsExtension(barrelRel));
1033 }
1034 
1035 if (!originalSpecifier.startsWith(".")) {
1036 return null;
1037 }
1038 
1039 let relativePath = toPosix(path.posix.relative(path.posix.dirname(consumerRel), withoutTsExtension(barrelRel)));
1040 if (relativePath.length === 0) {
1041 relativePath = ".";
1042 } else if (!relativePath.startsWith(".")) {
1043 relativePath = `./${relativePath}`;
1044 }
1045 return relativePath;
1046};
1047 
1048const barrelSpecifierTargetsExpectedFile = ({ env, consumerRel, barrelRel, specifier }) => {
1049 if (specifier.startsWith("@/")) {
1050 const normalized = `src/${specifier.slice(2)}`;
1051 if (normalized === withoutTsExtension(barrelRel)) {
1052 return true;
1053 }
1054 return (
1055 normalized === path.posix.dirname(barrelRel) && !hasShadowingSiblingSourceFile(env, path.posix.dirname(barrelRel))
1056 );
1057 }
1058 
1059 if (!specifier.startsWith(".")) {
1060 return false;
1061 }
1062 
1063 const resolved = toPosix(path.posix.normalize(path.posix.join(path.posix.dirname(consumerRel), specifier)));
1064 return resolved === withoutTsExtension(barrelRel);
1065};
1066 
1067const hasShadowingSiblingSourceFile = (env, dirRel) => {
1068 const siblingBase = dirRel;
1069 return env.fileInfos.has(`${siblingBase}.ts`) || env.fileInfos.has(`${siblingBase}.tsx`);
1070};
1071 
1072const toAliasSpecifier = (relPath) => {
1073 if (!relPath.startsWith("src/")) {
1074 throw new Error(`Cannot convert non-src path to alias: ${relPath}`);
1075 }
1076 const withoutSourceRoot = withoutTsExtension(relPath.slice("src/".length));
1077 return `@/${withoutSourceRoot}`;
1078};
1079 
1080const withoutTsExtension = (value) => value.replace(/\.(ts|tsx)$/, "");
1081 
1082const renderAnalysisSummary = ({ plan }) => {
1083 const lines = [
1084 "Import DAG analysis complete.",
1085 `Files: ${plan.summary.fileCount}`,
1086 `Edges: ${plan.summary.edgeCount}`,
1087 `Cycles: ${plan.summary.cycleCount}`,
1088 `Collapse candidates reviewed: ${plan.summary.candidateCount}`,
1089 `Barrel changes: ${plan.summary.barrelChangeCount}`,
1090 `File changes: ${plan.summary.fileChangeCount}`,
1091 `Blocked candidates: ${plan.summary.blockedCount}`,
1092 ];
1093 
1094 if (plan.barrelChanges.length > 0) {
1095 lines.push("", "Planned barrel changes:");
1096 for (const change of plan.barrelChanges) {
1097 lines.push(
1098 `- ${change.action} ${change.barrelFile} for ${change.modulesToAdd.length || change.modulesToExport.length} module(s)`,
1099 );
1100 }
1101 }
1102 
1103 if (plan.fileChanges.length > 0) {
1104 lines.push("", "Planned file changes:");
1105 for (const change of plan.fileChanges) {
1106 const collapseCount = change.collapseGroups.length;
1107 const mergeCount = change.mergeDuplicateSpecifiers.length;
1108 lines.push(`- ${change.file}: ${collapseCount} collapse group(s), ${mergeCount} merge-only specifier(s)`);
1109 }
1110 }
1111 
1112 if (plan.blocked.length > 0) {
1113 lines.push("", "Blocked:");
1114 for (const blocked of plan.blocked.slice(0, 20)) {
1115 lines.push(`- ${blocked.file} -> ${blocked.directory}: ${blocked.reason}`);
1116 }
1117 if (plan.blocked.length > 20) {
1118 lines.push(`- ... ${plan.blocked.length - 20} more`);
1119 }
1120 }
1121 
1122 return lines.join("\n");
1123};
1124 
1125const renderApplySummary = ({ touchedFiles, verification }) => {
1126 const lines = [
1127 "Import plan applied.",
1128 `Touched files: ${touchedFiles.length}`,
1129 `Verification cycles: ${verification.cycleCount}`,
1130 `Verification barrel changes remaining: ${verification.barrelChangeCount}`,
1131 `Verification file changes remaining: ${verification.fileChangeCount}`,
1132 ];
1133 
1134 if (touchedFiles.length > 0) {
1135 lines.push("", "Touched:");
1136 for (const file of touchedFiles) {
1137 lines.push(`- ${file}`);
1138 }
1139 }
1140 
1141 return lines.join("\n");
1142};
1143 
1144const countEdges = (graph) => {
1145 let count = 0;
1146 for (const deps of graph.values()) {
1147 count += deps.size;
1148 }
1149 return count;
1150};
1151 
1152const countCycles = (graph) => {
1153 const visited = new Set();
1154 const active = new Set();
1155 let cycles = 0;
1156 
1157 const visit = (node) => {
1158 visited.add(node);
1159 active.add(node);
1160 
1161 for (const dependency of graph.get(node) ?? []) {
1162 if (!graph.has(dependency)) {
1163 continue;
1164 }
1165 if (!visited.has(dependency)) {
1166 visit(dependency);
1167 continue;
1168 }
1169 if (active.has(dependency)) {
1170 cycles += 1;
1171 }
1172 }
1173 
1174 active.delete(node);
1175 };
1176 
1177 for (const node of [...graph.keys()].sort()) {
1178 if (!visited.has(node)) {
1179 visit(node);
1180 }
1181 }
1182 
1183 return cycles;
1184};
1185 
1186const cloneGraph = (graph) => {
1187 const clone = new Map();
1188 for (const [file, deps] of graph.entries()) {
1189 clone.set(file, new Set(deps));
1190 }
1191 return clone;
1192};
1193 
1194const ensureGraphNode = (graph, node) => {
1195 if (!graph.has(node)) {
1196 graph.set(node, new Set());
1197 }
1198};
1199 
1200const addToSet = (target, values) => {
1201 for (const value of values) {
1202 target.add(value);
1203 }
1204};
1205 
1206const toPosix = (value) => value.replaceAll(path.sep, "/");
1207 
1208const formatDiagnostic = (diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
1209 
1210main().catch((error) => {
1211 process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1212 process.exitCode = 1;
1213});