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
16 changes: 14 additions & 2 deletions src/tracker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import path from "node:path";
import type { Context, PreservedBarrel, UntraceableImport } from "./types.ts";
import { isIgnoredPath } from "./constants.ts";

export function isWithinDirectory(filePath: string, directory: string, pathApi: typeof path = path): boolean {
if (!filePath.startsWith(directory)) return false;
const relativePath = pathApi.relative(directory, filePath);
return (
relativePath !== "" &&
relativePath !== ".." &&
!relativePath.startsWith(`..${pathApi.sep}`) &&
!pathApi.isAbsolute(relativePath)
);
}

interface BarrelState {
consumers: Set<string>;
rewrittenConsumers: Set<string>;
Expand Down Expand Up @@ -57,7 +69,7 @@ export class BarrelTracker {
changed = false;
for (const [barrelPath, state] of this.barrels) {
if (toDelete.has(barrelPath)) continue;
if (!barrelPath.startsWith(ctx.base)) continue;
if (!isWithinDirectory(barrelPath, ctx.base)) continue;
if (isIgnoredPath(barrelPath, ctx.base)) continue;
if (state.dynamicConsumers.size > 0) continue;
if (ctx.preservedBarrels.has(barrelPath) || ctx.isPackageEntryPoint(barrelPath)) continue;
Expand All @@ -71,7 +83,7 @@ export class BarrelTracker {
}

for (const [barrelPath, state] of this.barrels) {
if (!barrelPath.startsWith(ctx.base)) continue;
if (!isWithinDirectory(barrelPath, ctx.base)) continue;
if (isIgnoredPath(barrelPath, ctx.base)) continue;

if (toDelete.has(barrelPath)) {
Expand Down
47 changes: 45 additions & 2 deletions test/integration/debarrelify.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { unbarrelify } from "../../src/main.ts";
import { copyFixture, read } from "./helpers.ts";

Expand Down Expand Up @@ -1426,4 +1428,45 @@ describe("unbarrelify integration tests", () => {
assert.ok(consumerContent.includes('./utils?raw"'));
});
});

describe("sibling directory with the same prefix as cwd", () => {
test("does not delete a barrel from a sibling directory whose consumers were not scanned", async (t) => {
const root = realpathSync(await mkdtemp(join(tmpdir(), "sibling-prefix-")));
t.after(() => rm(root, { recursive: true, force: true }));

const files: Record<string, string> = {
"app/consumer.ts": [
'import { local } from "./inner/index.ts";',
'import { shared } from "../app-shared/internal/index.ts";',
"export const values = [local, shared];",
"",
].join("\n"),
"app/inner/index.ts": 'export * from "./local.ts";\n',
"app/inner/local.ts": "export const local = 1;\n",
"app-shared/internal/index.ts": 'export * from "./shared.ts";\n',
"app-shared/internal/shared.ts": "export const shared = 2;\n",
"app-shared/other-consumer.ts": 'import { shared } from "./internal/index.ts";\nexport const value = shared;\n',
};
for (const [file, content] of Object.entries(files)) {
await mkdir(dirname(join(root, file)), { recursive: true });
await writeFile(join(root, file), content);
}

const result = await unbarrelify({
cwd: join(root, "app"),
files: ["**/*.ts"],
skip: [],
write: true,
});

const siblingBarrel = join(root, "app-shared/internal/index.ts");
assert.ok(!result.deleted.includes(siblingBarrel));
assert.ok(existsSync(siblingBarrel));
assert.equal(await read(join(root, "app-shared/other-consumer.ts")), files["app-shared/other-consumer.ts"]);

const insideBarrel = join(root, "app/inner/index.ts");
assert.ok(result.deleted.includes(insideBarrel));
assert.ok(!existsSync(insideBarrel));
});
});
});
105 changes: 105 additions & 0 deletions test/unit/tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { BarrelTracker, isWithinDirectory } from "../../src/tracker.ts";
import type { Context, File } from "../../src/types.ts";

function createTestContext(base: string, tracker: BarrelTracker): Context {
return {
base,
only: [],
ext: undefined,
write: false,
check: false,
unsafeNamespace: false,
organizeImports: false,
aliases: null,
projectFiles: [],
preservedBarrels: new Set(),
includedBarrels: new Set(),
fileCache: new Map<string, File>(),
isPackageEntryPoint: () => false,
progress: () => {},
tracker,
};
}

function registerRewrittenBarrel(tracker: BarrelTracker, barrelPath: string, consumerPath: string): void {
tracker.register(barrelPath);
tracker.addConsumer(barrelPath, consumerPath);
tracker.markRewritten(barrelPath, consumerPath);
}

describe("BarrelTracker", () => {
describe("classify", () => {
it("only classifies barrels inside the base directory", () => {
const tracker = new BarrelTracker();
const consumer = "/project/app/consumer.ts";
const insideBarrel = "/project/app/inner/index.ts";
const outsideBarrel = "/project/lib/index.ts";
const siblingPrefixBarrel = "/project/app-shared/internal/index.ts";

registerRewrittenBarrel(tracker, insideBarrel, consumer);
registerRewrittenBarrel(tracker, outsideBarrel, consumer);
registerRewrittenBarrel(tracker, siblingPrefixBarrel, consumer);

const { deleted, preserved } = tracker.classify(createTestContext("/project/app", tracker));

assert.deepEqual(deleted, [insideBarrel]);
assert.deepEqual(preserved, []);
});

it("classifies barrels when the base is the filesystem root", () => {
const tracker = new BarrelTracker();
const barrel = "/project/inner/index.ts";
registerRewrittenBarrel(tracker, barrel, "/project/consumer.ts");

const { deleted } = tracker.classify(createTestContext("/", tracker));

assert.deepEqual(deleted, [barrel]);
});

it("classifies barrels when the base has a trailing separator", () => {
const tracker = new BarrelTracker();
const consumer = "/project/app/consumer.ts";
const insideBarrel = "/project/app/inner/index.ts";
registerRewrittenBarrel(tracker, insideBarrel, consumer);
registerRewrittenBarrel(tracker, "/project/app-shared/internal/index.ts", consumer);

const { deleted } = tracker.classify(createTestContext("/project/app/", tracker));

assert.deepEqual(deleted, [insideBarrel]);
});
});

describe("isWithinDirectory", () => {
it("respects directory boundaries on POSIX paths", () => {
const { posix } = path;
assert.equal(isWithinDirectory("/project/app/inner/index.ts", "/project/app", posix), true);
assert.equal(isWithinDirectory("/project/app/..cache/index.ts", "/project/app", posix), true);
assert.equal(isWithinDirectory("/project/app-shared/index.ts", "/project/app", posix), false);
assert.equal(isWithinDirectory("/project/lib/index.ts", "/project/app", posix), false);
assert.equal(isWithinDirectory("/project/app", "/project/app", posix), false);
assert.equal(isWithinDirectory("/project/app/inner/index.ts", "/project/app/", posix), true);
assert.equal(isWithinDirectory("/project/app-shared/index.ts", "/project/app/", posix), false);
assert.equal(isWithinDirectory("/project/index.ts", "/", posix), true);
});

it("respects directory boundaries on Windows paths", () => {
const { win32 } = path;
assert.equal(isWithinDirectory("C:\\project\\app\\inner\\index.ts", "C:\\project\\app", win32), true);
assert.equal(isWithinDirectory("C:/project/app/inner/index.ts", "C:/project/app", win32), true);
assert.equal(isWithinDirectory("C:\\project\\app-shared\\index.ts", "C:\\project\\app", win32), false);
assert.equal(isWithinDirectory("C:/project/app-shared/index.ts", "C:\\project\\app", win32), false);
assert.equal(isWithinDirectory("D:\\project\\app\\inner\\index.ts", "C:\\project\\app", win32), false);
assert.equal(isWithinDirectory("C:\\project\\app\\inner\\index.ts", "C:\\project\\app\\", win32), true);
assert.equal(isWithinDirectory("C:\\project\\index.ts", "C:\\", win32), true);
});

it("does not admit path spellings rejected by the original prefix check", () => {
const { win32 } = path;
assert.equal(isWithinDirectory("C:/project/app/index.ts", "C:\\project\\app", win32), false);
assert.equal(isWithinDirectory("C:\\PROJECT\\app\\index.ts", "C:\\project\\app", win32), false);
});
});
});