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
47 changes: 28 additions & 19 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFile, realpath, unlink, writeFile } from "node:fs/promises";
import { styleText } from "node:util";
import { glob } from "tinyglobby";
import ts from "typescript";
import { analyzeFile, buildExportMap, checkIsBarrel, parseSpecifier } from "./analyzer.ts";
import { analyzeFile, parseSpecifier } from "./analyzer.ts";
import { initProjectConfig } from "./config.ts";
import { extractImportLine, formatExampleDiff, type ExampleCandidate } from "./diff.ts";
import { resolveModule } from "./resolver.ts";
Expand All @@ -24,10 +24,6 @@ function detectSingleQuote(sourceFile: ts.SourceFile): boolean {

const DEFAULT_IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/*.d.ts"];

async function read(filePath: string): Promise<string> {
return readFile(filePath, "utf-8");
}

async function write(filePath: string, content: string): Promise<void> {
return writeFile(filePath, content, "utf-8");
}
Expand Down Expand Up @@ -94,12 +90,12 @@ async function createContext(options: Options): Promise<Context> {

async function registerExplicitBarrels(paths: string[], ctx: Context): Promise<void> {
const rel = (p: string) => relative(ctx.base, p);
const selectedFiles = new Map<string, File>();

for (const path of paths) {
const barrelPath = isAbsolute(path) ? path : resolve(ctx.base, path);
const content = await read(barrelPath);
const sourceFile = ts.createSourceFile(barrelPath, content, ts.ScriptTarget.Latest);
const isBarrelFile = checkIsBarrel(sourceFile) || ctx.includedBarrels.has(barrelPath);
const analysis = await analyzeFile(barrelPath, ctx);
const isBarrelFile = analysis.isBarrel || ctx.includedBarrels.has(barrelPath);

if (!isBarrelFile) {
console.log(`File is not a barrel: ${rel(barrelPath)}`);
Expand All @@ -111,16 +107,20 @@ async function registerExplicitBarrels(paths: string[], ctx: Context): Promise<v
continue;
}

ctx.fileCache.set(barrelPath, {
isBarrel: true,
imports: new Map(),
exports: await buildExportMap(sourceFile, ctx.aliases),
sourceFile,
dynamicImports: new Set(),
});
ctx.fileCache.set(barrelPath, { ...analysis, isBarrel: true });

ctx.tracker.register(barrelPath);
ctx.progress({ type: "barrel", path: barrelPath });
selectedFiles.set(barrelPath, analysis);
}

for (const [barrelPath, analysis] of selectedFiles) {
trackExplicitBarrelConsumers(analysis, barrelPath, ctx);
for (const [importedFilePath, importSet] of analysis.imports) {
for (const item of importSet) {
if (item.type === "export" && item.name === "*") ctx.tracker.addStarConsumer(importedFilePath, barrelPath);
}
}
}
}

Expand Down Expand Up @@ -176,6 +176,8 @@ async function processFiles(files: string[], ctx: Context): Promise<Result> {

if (ctx.only.length === 0) {
await discoverBarrels(analysis, filePath, ctx);
} else {
trackExplicitBarrelConsumers(analysis, filePath, ctx);
}

const rewrites = await buildRewrites(analysis, filePath, ctx);
Expand Down Expand Up @@ -203,9 +205,7 @@ async function processFiles(files: string[], ctx: Context): Promise<Result> {

ctx.progress({ type: "rewriting" });

if (ctx.only.length === 0) {
await trackNonTsConsumers(ctx);
}
await trackNonTsConsumers(ctx);

ctx.progress({ type: "done" });

Expand Down Expand Up @@ -287,6 +287,15 @@ async function discoverBarrels(analysis: File, consumerPath: string, ctx: Contex
}
}

function trackExplicitBarrelConsumers(analysis: File, consumerPath: string, ctx: Context): void {
for (const dynamicImport of analysis.dynamicImports) {
ctx.tracker.addDynamicConsumer(dynamicImport, consumerPath);
}
for (const [importedFilePath] of analysis.imports) {
ctx.tracker.addConsumer(importedFilePath, consumerPath);
}
}

function markRewrittenBarrels(analysis: File, consumerPath: string, rewrites: Rewrites, ctx: Context): void {
const rewrittenPositions = new Set(rewrites.keys());

Expand Down Expand Up @@ -348,7 +357,7 @@ async function trackNonTsConsumers(ctx: Context): Promise<void> {

if (ctx.tracker.has(resolved)) {
ctx.tracker.addConsumer(resolved, nonTsFile);
} else {
} else if (ctx.only.length === 0) {
const analysis = await analyzeFile(resolved, ctx);
if (isBarrel(resolved, analysis, ctx)) {
ctx.tracker.register(resolved);
Expand Down
5 changes: 2 additions & 3 deletions src/tracker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Context, PreservedBarrel, UntraceableImport } from "./types.ts";
import { isIgnoredPath } from "./constants.ts";
import { isIgnoredPath, JS_EXT_PATTERN } from "./constants.ts";

interface BarrelState {
consumers: Set<string>;
Expand Down Expand Up @@ -120,8 +120,7 @@ export class BarrelTracker {
starConsumers.push(consumer);
continue;
}
const isTs = consumer.endsWith(".ts") || consumer.endsWith(".tsx");
if (isTs) nsConsumers.push(consumer);
if (JS_EXT_PATTERN.test(consumer)) nsConsumers.push(consumer);
else nonTsConsumers.push(consumer);
}

Expand Down
270 changes: 270 additions & 0 deletions test/integration/only-consumers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { test, describe } from "node:test";
import type { TestContext } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import ts from "typescript";
import { unbarrelify } from "../../src/main.ts";
import type { Options } from "../../src/types.ts";

async function project(t: TestContext, files: Record<string, string>): Promise<string> {
const dir = realpathSync(await mkdtemp(join(tmpdir(), "only-consumers-")));
t.after(() => rm(dir, { recursive: true, force: true }));
for (const [name, content] of Object.entries(files)) {
await writeFile(join(dir, name), content, "utf-8");
}
return dir;
}

function run(dir: string, entry: string): { status: number | null; stdout: string } {
const result = spawnSync(process.execPath, [join(dir, entry)], { cwd: dir, encoding: "utf-8", timeout: 20_000 });
return { status: result.status, stdout: result.stdout };
}

const dynamicProject = {
"value.mjs": "export const value = 42;\n",
"barrel.mjs": 'export { value } from "./value.mjs";\n',
"consumer.mjs": 'const loaded = await import("./barrel.mjs");\nconsole.log(loaded.value);\n',
};

for (const only of [[], ["inner.mjs", "outer.mjs"], ["outer.mjs", "inner.mjs"]]) {
test(`a forced barrel retains its dynamic dependency with selection ${JSON.stringify(only)}`, async (t) => {
const dir = await project(t, {
"value.mjs": "export const value = 42;\n",
"inner.mjs": 'export { value } from "./value.mjs";\n',
"outer.mjs": 'export { value } from "./value.mjs";\nawait import("./inner.mjs");\n',
"consumer.mjs": 'console.log((await import("./outer.mjs")).value);\n',
});
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
const result = await unbarrelify(options(dir, { only, barrel: ["outer.mjs"] }));
assert.deepEqual(result.deleted, []);
for (const [barrel, consumer] of [
["inner.mjs", "outer.mjs"],
["outer.mjs", "consumer.mjs"],
]) {
assert.deepEqual(
result.preserved.find(({ path }) => path === join(dir, barrel)),
{
path: join(dir, barrel),
reason: "dynamic-import",
consumers: [join(dir, consumer)],
},
);
}
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});
}

for (const only of [[], ["inner.mjs", "outer.mjs"], ["outer.mjs", "inner.mjs"]]) {
test(`a forced barrel star re-export is reported with selection ${JSON.stringify(only)}`, async (t) => {
const dir = await project(t, {
"value.mjs": "export const value = 42;\n",
"inner.mjs": 'export { value } from "./value.mjs";\n',
"outer.mjs": 'export * from "./inner.mjs";\nexport const own = true;\n',
"consumer.mjs": 'console.log((await import("./outer.mjs")).value);\n',
});
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
const result = await unbarrelify(options(dir, { only, barrel: ["outer.mjs"] }));
assert.deepEqual(result.deleted, []);
assert.deepEqual(
result.preserved.find(({ path }) => path === join(dir, "inner.mjs")),
{ path: join(dir, "inner.mjs"), reason: "star-reexport", consumers: [join(dir, "outer.mjs")] },
);
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});
}

for (const multipleSources of [true, false]) {
test(`an explicit barrel has a TypeScript namespace consumer with ${multipleSources ? "multiple sources" : "one source"}`, async (t) => {
const original = 'import * as ns from "./barrel.mjs";\nconsole.log(ns.a + ns.b);\n';
const dir = await project(t, {
"a.mjs": multipleSources ? "export const a = 40;\n" : "export const a = 40; export const b = 2;\n",
"b.mjs": "export const b = 2;\n",
"barrel.mjs": multipleSources
? 'export { a } from "./a.mjs";\nexport { b } from "./b.mjs";\n'
: 'export { a, b } from "./a.mjs";\n',
"consumer.ts": original,
});
const execute = async () => {
const source = await readFile(join(dir, "consumer.ts"), "utf-8");
const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext } });
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", outputText], {
cwd: dir,
encoding: "utf-8",
timeout: 20_000,
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "42\n");
};
await execute();
const result = await unbarrelify(options(dir, { only: ["barrel.mjs"], files: ["**/*.ts", "**/*.mjs"] }));
if (multipleSources) {
assert.deepEqual(result.deleted, []);
assert.deepEqual(result.preserved, [
{
path: join(dir, "barrel.mjs"),
reason: "namespace-import",
consumers: [join(dir, "consumer.ts")],
},
]);
assert.equal(await readFile(join(dir, "consumer.ts"), "utf-8"), original);
} else {
assert.deepEqual(result.deleted, [join(dir, "barrel.mjs")]);
assert.deepEqual(result.preserved, []);
assert.match(await readFile(join(dir, "consumer.ts"), "utf-8"), /\.\/a\.mjs/);
}
await execute();
});
}

function options(dir: string, extra: Partial<Options>): Options {
return { cwd: dir, files: ["**/*.mjs"], ext: ".mjs", write: true, ...extra };
}

describe("--only keeps consumer safety discovery", () => {
test("ordinary mode preserves a dynamically imported barrel (control)", async (t) => {
const dir = await project(t, dynamicProject);
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });

const result = await unbarrelify(options(dir, {}));

assert.deepEqual(result.deleted, []);
assert.deepEqual(result.preserved, [
{ path: join(dir, "barrel.mjs"), reason: "dynamic-import", consumers: [join(dir, "consumer.mjs")] },
]);
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});

test("preserves an explicit barrel that is dynamically imported", async (t) => {
const dir = await project(t, dynamicProject);
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });

const result = await unbarrelify(options(dir, { only: ["barrel.mjs"] }));

assert.deepEqual(result.deleted, []);
assert.deepEqual(result.preserved, [
{ path: join(dir, "barrel.mjs"), reason: "dynamic-import", consumers: [join(dir, "consumer.mjs")] },
]);
assert.ok(existsSync(join(dir, "barrel.mjs")));
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});

test("dry-run and check report the explicit dynamic barrel as preserved", async (t) => {
for (const extra of [{ write: false }, { write: true, check: true }] satisfies Partial<Options>[]) {
const dir = await project(t, dynamicProject);
const result = await unbarrelify(options(dir, { only: ["barrel.mjs"], ...extra }));

assert.deepEqual(result.deleted, []);
assert.deepEqual(result.preserved, [
{ path: join(dir, "barrel.mjs"), reason: "dynamic-import", consumers: [join(dir, "consumer.mjs")] },
]);
assert.ok(existsSync(join(dir, "barrel.mjs")));
}
});

test("still rewrites static consumers and deletes the explicit barrel", async (t) => {
const dir = await project(t, {
"value.mjs": "export const value = 42;\n",
"barrel.mjs": 'export { value } from "./value.mjs";\n',
"consumer.mjs": 'import { value } from "./barrel.mjs";\nconsole.log(value);\n',
});
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });

const result = await unbarrelify(options(dir, { only: ["barrel.mjs"] }));

assert.deepEqual(result.modified, [join(dir, "consumer.mjs")]);
assert.deepEqual(result.deleted, [join(dir, "barrel.mjs")]);
assert.deepEqual(result.preserved, []);
assert.ok(!existsSync(join(dir, "barrel.mjs")));
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});

test("only restricts target barrels: nonmatched barrels are not tracked", async (t) => {
const dir = await project(t, {
"value.mjs": "export const value = 42;\n",
"other.mjs": "export const other = 7;\n",
"barrel.mjs": 'export { value } from "./value.mjs";\n',
"unselected.mjs": 'export { other } from "./other.mjs";\n',
"consumer.mjs":
'const loaded = await import("./barrel.mjs");\nimport { other } from "./unselected.mjs";\nconsole.log(loaded.value, other);\n',
});
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42 7\n" });

const result = await unbarrelify(options(dir, { only: ["barrel.mjs"] }));

assert.deepEqual(result.modified, []);
assert.deepEqual(result.deleted, []);
assert.deepEqual(result.preserved, [
{ path: join(dir, "barrel.mjs"), reason: "dynamic-import", consumers: [join(dir, "consumer.mjs")] },
]);
assert.ok(existsSync(join(dir, "unselected.mjs")));
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42 7\n" });
});

test("preserves an explicit barrel that a selected barrel still re-exports", async (t) => {
const dir = await project(t, {
"value.mjs": "export const value = 42;\n",
"inner.mjs": 'export { value } from "./value.mjs";\n',
"outer.mjs": 'export { value } from "./inner.mjs";\n',
"consumer.mjs": 'const loaded = await import("./outer.mjs");\nconsole.log(loaded.value);\n',
});
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });

const result = await unbarrelify(options(dir, { only: ["inner.mjs", "outer.mjs"] }));

assert.deepEqual(result.deleted, []);
assert.ok(existsSync(join(dir, "inner.mjs")));
assert.ok(existsSync(join(dir, "outer.mjs")));
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});

test("handles a namespace consumer of an explicit barrel like ordinary mode", async (t) => {
const files = {
"a.mjs": "export const a = 40;\n",
"b.mjs": "export const b = 2;\n",
"barrel.mjs": 'export { a } from "./a.mjs";\nexport { b } from "./b.mjs";\n',
"consumer.mjs": 'import * as ns from "./barrel.mjs";\nconsole.log(ns.a + ns.b);\n',
};
const ordinaryDir = await project(t, files);
const ordinary = await unbarrelify(options(ordinaryDir, {}));
const dir = await project(t, files);
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });

const result = await unbarrelify(options(dir, { only: ["barrel.mjs"] }));

const relative = (paths: string[], base: string) => paths.map((p) => p.slice(base.length));
assert.deepEqual(result.preserved, [
{ path: join(dir, "barrel.mjs"), reason: "namespace-import", consumers: [join(dir, "consumer.mjs")] },
]);
assert.deepEqual(relative(result.deleted, dir), relative(ordinary.deleted, ordinaryDir));
assert.deepEqual(
result.preserved.map(({ path, reason, consumers }) => [path.slice(dir.length), reason, relative(consumers, dir)]),
ordinary.preserved.map(({ path, reason, consumers }) => [
path.slice(ordinaryDir.length),
reason,
relative(consumers, ordinaryDir),
]),
);
assert.deepEqual(run(dir, "consumer.mjs"), { status: 0, stdout: "42\n" });
assert.deepEqual(run(ordinaryDir, "consumer.mjs"), { status: 0, stdout: "42\n" });
});

test("preserves an explicit barrel imported by a non-TypeScript consumer", async (t) => {
const dir = await project(t, {
"value.mjs": "export const value = 42;\n",
"barrel.mjs": 'export { value } from "./value.mjs";\n',
"page.vue": '<script setup>\nimport { value } from "./barrel.mjs";\n</script>\n',
});

const result = await unbarrelify(options(dir, { only: ["barrel.mjs"] }));

assert.deepEqual(result.deleted, []);
assert.deepEqual(result.preserved, [
{ path: join(dir, "barrel.mjs"), reason: "non-ts-import", consumers: [join(dir, "page.vue")] },
]);
assert.ok(existsSync(join(dir, "barrel.mjs")));
});
});