Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function analyzeFile(filePath: string, ctx: Context): Promise<File>
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest);

const isBarrel = checkIsBarrel(sourceFile);
const exports = await buildExportMap(sourceFile, ctx.aliases);
const exports = await buildExportMap(sourceFile, ctx.aliases, (ctx.sourceFileCache ??= new Map()));
const imports = buildImportMap(sourceFile, ctx.aliases);
const dynamicImports = isBarrel ? new Set<string>() : findDynamicImports(sourceFile, ctx.aliases);

Expand Down Expand Up @@ -87,9 +87,10 @@ function extractExportedNames(sourceFile: ts.SourceFile): Set<string> {
return names;
}

const sourceFileCache = new Map<string, ts.SourceFile>();

export async function getExportedNames(filePath: string): Promise<Set<string>> {
export async function getExportedNames(
filePath: string,
sourceFileCache = new Map<string, ts.SourceFile>(),
): Promise<Set<string>> {
let sourceFile = sourceFileCache.get(filePath);
if (!sourceFile) {
const content = await readFile(filePath, "utf-8");
Expand Down Expand Up @@ -118,7 +119,11 @@ function extractExportedName(node: ts.Node, names: Set<string>): void {
}
}

export async function buildExportMap(sourceFile: ts.SourceFile, aliases: PathAliases | null): Promise<ExportMap> {
export async function buildExportMap(
sourceFile: ts.SourceFile,
aliases: PathAliases | null,
sourceFileCache = new Map<string, ts.SourceFile>(),
): Promise<ExportMap> {
const exports: ExportMap = new Map();

for (const node of sourceFile.statements) {
Expand All @@ -133,7 +138,7 @@ export async function buildExportMap(sourceFile: ts.SourceFile, aliases: PathAli
const pos = { start: node.getStart(sourceFile), end: node.end };

if (isAbsolute(resolvedPath)) {
await processLocalExport(exports, node, resolvedPath, specifier, pos);
await processLocalExport(exports, node, resolvedPath, specifier, pos, sourceFileCache);
} else {
processExternalExport(exports, node, specifier, pos);
}
Expand All @@ -148,6 +153,7 @@ async function processLocalExport(
resolvedPath: string,
specifier: string,
pos: { start: number; end: number },
sourceFileCache: Map<string, ts.SourceFile>,
): Promise<void> {
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
const exportedNames = new Set<string>();
Expand Down Expand Up @@ -175,7 +181,7 @@ async function processLocalExport(
} else {
const namespace =
node.exportClause && ts.isNamespaceExport(node.exportClause) ? node.exportClause.name.text : undefined;
const exportedNames = await getExportedNames(resolvedPath);
const exportedNames = await getExportedNames(resolvedPath, sourceFileCache);

mergeExport(exports, resolvedPath, {
specifier,
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async function registerExplicitBarrels(paths: string[], ctx: Context): Promise<v
ctx.fileCache.set(barrelPath, {
isBarrel: true,
imports: new Map(),
exports: await buildExportMap(sourceFile, ctx.aliases),
exports: await buildExportMap(sourceFile, ctx.aliases, (ctx.sourceFileCache ??= new Map())),
sourceFile,
dynamicImports: new Set(),
});
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface Context {
preservedBarrels: Set<string>;
includedBarrels: Set<string>;
fileCache: Map<string, File>;
sourceFileCache?: Map<string, SourceFile>;
isPackageEntryPoint: (filePath: string) => boolean;
tracker: BarrelTracker;
}
Expand Down
138 changes: 138 additions & 0 deletions test/integration/run-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs, { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { unbarrelify } from "../../src/index.ts";
import { snapshotDir } from "./helpers.ts";

const barrel = 'export * from "./a.mjs";\nexport * from "./b.mjs";\n';

async function createProject(t: TestContext): Promise<string> {
const cwd = await realpath(await mkdtemp(join(tmpdir(), "unbarrelify-run-cache-")));
t.after(() => rm(cwd, { recursive: true, force: true }));
return cwd;
}

async function writeSources(cwd: string, moved: boolean): Promise<void> {
await writeFile(join(cwd, "a.mjs"), moved ? "export const bar = 300;\n" : "export const foo = 100;\n");
await writeFile(join(cwd, "b.mjs"), moved ? "export const foo = 400;\n" : "export const bar = 200;\n");
}

function runConsumer(cwd: string, name: string, expected: string): void {
const result = spawnSync(process.execPath, [join(cwd, name)], { encoding: "utf8", timeout: 5000 });
assert.equal(result.error, undefined);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), expected);
}

for (const explicit of [false, true]) {
const mode = explicit ? "only" : "discovery";

test(`public API refreshes exported names after a dry-run (${mode})`, { timeout: 15000 }, async (t) => {
const cwd = await createProject(t);
await writeSources(cwd, false);
await writeFile(join(cwd, "barrel.mjs"), barrel);
await writeFile(join(cwd, "consumer.mjs"), 'import { foo } from "./barrel.mjs";\nconsole.log(foo);\n');
const options = { cwd, ext: ".mjs", only: explicit ? ["barrel.mjs"] : [] };
const before = await snapshotDir(cwd);
runConsumer(cwd, "consumer.mjs", "100");

const dryRun = await unbarrelify({ ...options, write: false });
assert.deepEqual(dryRun.modified, [join(cwd, "consumer.mjs")]);
assert.deepEqual(dryRun.deleted, [join(cwd, "barrel.mjs")]);
assert.deepEqual(await snapshotDir(cwd), before);

await writeSources(cwd, true);
runConsumer(cwd, "consumer.mjs", "400");
const result = await unbarrelify({ ...options, write: true });

assert.deepEqual(result.modified, [join(cwd, "consumer.mjs")]);
assert.deepEqual(result.deleted, [join(cwd, "barrel.mjs")]);
assert.deepEqual(result.untraceableImports, []);
await assert.rejects(readFile(join(cwd, "barrel.mjs")), { code: "ENOENT" });
runConsumer(cwd, "consumer.mjs", "400");
assert.match(await readFile(join(cwd, "consumer.mjs"), "utf8"), /from ["']\.\/b\.mjs["']/);
});

test(`overlapping API runs keep independent reusable export snapshots (${mode})`, { timeout: 15000 }, async (t) => {
const cwd = await createProject(t);
await writeSources(cwd, false);
for (const name of ["first.mjs", "second.mjs", "third.mjs", "other.mjs"]) {
await writeFile(join(cwd, name), barrel);
}
await writeFile(
join(cwd, "consumer.mjs"),
'import { foo } from "./first.mjs";\nimport { bar } from "./second.mjs";\n' +
'import { foo as again } from "./third.mjs";\nconsole.log(foo + bar + again);\n',
);
await writeFile(
join(cwd, "other-consumer.mjs"),
'import { foo, bar } from "./other.mjs";\nconsole.log(foo + bar);\n',
);

const paused = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const reads = new Map<string, number>();
const originalReadFile = fs.readFile;
const readMock = t.mock.method(fs, "readFile", async (...args: Parameters<typeof fs.readFile>) => {
const path = String(args[0]);
reads.set(path, (reads.get(path) ?? 0) + 1);
const content = await originalReadFile(...args);
if (path === join(cwd, "second.mjs")) {
paused.resolve();
await resume.promise;
}
return content;
});
syncBuiltinESMExports();

const first = unbarrelify({
cwd,
ext: ".mjs",
files: ["consumer.mjs"],
only: explicit ? ["first.mjs", "second.mjs", "third.mjs"] : [],
write: true,
});
try {
await Promise.race([
paused.promise,
first.then(() => {
throw new Error("First run finished before reaching the second barrel");
}),
]);
// The first run has read the original exports, but is still in progress.
await writeSources(cwd, true);
const second = await unbarrelify({
cwd,
ext: ".mjs",
files: ["other-consumer.mjs"],
only: explicit ? ["other.mjs"] : [],
write: true,
});
assert.deepEqual(second.modified, [join(cwd, "other-consumer.mjs")]);
assert.deepEqual(second.untraceableImports, []);
runConsumer(cwd, "other-consumer.mjs", "700");

// Restore the first run's sources before it resumes and executes its consumer.
await writeSources(cwd, false);
resume.resolve();
const result = await first;
assert.deepEqual(result.modified, [join(cwd, "consumer.mjs")]);
assert.deepEqual(result.untraceableImports, []);
runConsumer(cwd, "consumer.mjs", "400");
for (const name of ["a.mjs", "b.mjs"]) {
// At most one export scan and one full analysis per source per run,
// regardless of the number of barrels re-exporting that source.
assert.ok((reads.get(join(cwd, name)) ?? 0) <= 4, `${name} was reread for each barrel`);
}
} finally {
resume.resolve();
await Promise.allSettled([first]);
readMock.mock.restore();
syncBuiltinESMExports();
}
});
}