Skip to content
Merged
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
84 changes: 84 additions & 0 deletions scripts/lib/third-party-licenses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,90 @@ describe("third-party license generation", () => {
expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true);
});

it("leaves type-only packages out of the notices but still walks their dependencies", async () => {
const fixture = await createFixture();
const modules = NodePath.join(fixture.root, "node_modules");
const write = async (name: string, packageJson: Record<string, unknown>) => {
await writeJson(NodePath.join(modules, name, "package.json"), {
name,
version: "1.0.0",
license: "MIT",
...packageJson,
});
await NodeFSP.writeFile(NodePath.join(modules, name, "LICENSE"), `${name} license\n`, "utf8");
};
await writeJson(fixture.appManifest, {
name: "fixture-app",
dependencies: { "demo-dependency": "1.2.3", "@types/demo": "1.0.0" },
});
// The DefinitelyTyped shape, and the csstype / undici-types one it pulls in.
await write("@types/demo", {
main: "",
types: "index.d.ts",
exports: {
".": { types: { import: "./index.d.mts", default: "./index.d.ts" } },
"./package.json": "./package.json",
},
dependencies: { "demo-css-types": "1.0.0", "demo-runtime": "1.0.0" },
});
await write("demo-css-types", { main: "", types: "index.d.ts" });
await write("demo-runtime", { main: "index.js" });

const manifest = await generateThirdPartyLicenseManifest({
configFile: fixture.configFile,
packageManifests: [{ bundle: "web", path: fixture.appManifest }],
});

const names = manifest.entries.map((entry) => entry.name);
expect(names).toContain("demo-dependency");
expect(names).toContain("demo-runtime");
expect(names).not.toContain("@types/demo");
expect(names).not.toContain("demo-css-types");
});

it.each([
{
shape: "a JS entry next to its declarations",
// The @shikijs/types shape: a types condition next to a real JS entry.
entry: {
main: "./dist/index.mjs",
exports: { ".": { types: "./dist/index.d.mts", import: "./dist/index.mjs" } },
},
indexFile: null,
},
{ shape: "only a stylesheet", entry: { main: "", style: "dist/theme.css" }, indexFile: null },
{
shape: "only a react-native entry",
entry: { main: "", "react-native": "lib/index.native.js" },
indexFile: null,
},
{ shape: "an index.js behind an empty main", entry: { main: "" }, indexFile: "index.js" },
{ shape: "an index.node addon and no main", entry: {}, indexFile: "index.node" },
])("keeps a package that declares types but ships $shape", async ({ entry, indexFile }) => {
const fixture = await createFixture();
await writeJson(NodePath.join(fixture.dependencyRoot, "package.json"), {
name: "demo-dependency",
version: "1.2.3",
license: "MIT",
types: "./dist/index.d.mts",
...entry,
repository: "example/demo-dependency",
});
if (indexFile !== "index.js") {
await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "index.js"));
}
if (indexFile !== null) {
await NodeFSP.writeFile(NodePath.join(fixture.dependencyRoot, indexFile), "", "utf8");
}

const manifest = await generateThirdPartyLicenseManifest({
configFile: fixture.configFile,
packageManifests: [{ bundle: "web", path: fixture.appManifest }],
});

expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true);
});

it("includes custom notices selected by the dev server bundle", async () => {
const fixture = await createFixture();
await writeJson(fixture.configFile, {
Expand Down
86 changes: 85 additions & 1 deletion scripts/lib/third-party-licenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,23 @@ interface GeneratedNoticeConfigEntry {
}

interface PackageJson {
readonly bin?: unknown;
readonly browser?: unknown;
readonly dependencies?: Readonly<Record<string, string>>;
readonly exports?: unknown;
readonly homepage?: unknown;
readonly license?: unknown;
readonly licenses?: unknown;
readonly main?: unknown;
readonly module?: unknown;
readonly name?: unknown;
readonly optionalDependencies?: Readonly<Record<string, string>>;
readonly "react-native"?: unknown;
readonly repository?: unknown;
readonly sass?: unknown;
readonly style?: unknown;
readonly types?: unknown;
readonly typings?: unknown;
readonly version?: unknown;
}

Expand Down Expand Up @@ -510,6 +520,76 @@ function dependencyNames(packageJson: PackageJson): ReadonlyArray<string> {
].sort((left, right) => left.localeCompare(right));
}

interface ExportTarget {
readonly path: string;
readonly underTypesCondition: boolean;
}

function exportTargets(value: unknown, underTypesCondition = false): ReadonlyArray<ExportTarget> {
if (typeof value === "string") return [{ path: value, underTypesCondition }];
if (Array.isArray(value))
return value.flatMap((item) => exportTargets(item, underTypesCondition));
if (value === null || typeof value !== "object") return [];
return Object.entries(value).flatMap(([key, item]) =>
exportTargets(item, underTypesCondition || key.startsWith("types") || key === "typings"),
);
}

/** Declarations, or the package's own manifest that many packages also export. Neither is code. */
function isTypeDeclaration(target: ExportTarget): boolean {
return (
target.underTypesCondition ||
/\.d\.[cm]?ts$/u.test(target.path) ||
target.path === "./package.json"
);
}

function isDeclared(value: unknown): boolean {
if (typeof value === "string") return value.trim() !== "";
return value !== null && typeof value === "object" && Object.keys(value).length > 0;
}

async function fileExists(path: string): Promise<boolean> {
try {
await NodeFSP.access(path);
return true;
} catch {
return false;
}
}

/**
* A package that only describes types (DefinitelyTyped, csstype, undici-types): it declares types
* and has no JS entry, so nothing of it ships. A types condition or a name with "types" in it is not
* enough on its own: `@shikijs/types` exports real code next to its declarations.
*/
async function isTypeOnlyPackage(packageJson: PackageJson, packageRoot: string): Promise<boolean> {
const targets = exportTargets(packageJson.exports);
const declaresTypes =
isDeclared(packageJson.types) ||
isDeclared(packageJson.typings) ||
targets.some(isTypeDeclaration);
if (!declaresTypes) return false;
const entries = [
packageJson.main,
packageJson.module,
packageJson.browser,
packageJson["react-native"],
packageJson.bin,
packageJson.style,
packageJson.sass,
];
if (entries.some(isDeclared) || targets.some((target) => !isTypeDeclaration(target))) {
return false;
}
// Without `exports`, Node still loads the package's index file, an empty `main` included.
if (packageJson.exports !== undefined && packageJson.exports !== null) return true;
for (const indexFile of ["index.js", "index.json", "index.node"]) {
if (await fileExists(NodePath.join(packageRoot, indexFile))) return false;
}
return true;
}

async function collectProductionDependencyPackages(
packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>,
): Promise<PackageCollection> {
Expand All @@ -530,7 +610,11 @@ async function collectProductionDependencyPackages(
const dependencyPackageJsonPath = NodePath.join(resolved.packageRoot, "package.json");
const name =
typeof resolved.packageJson.name === "string" ? resolved.packageJson.name : dependencyName;
if (!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) {
// Type-only packages ship nothing, but what they depend on may, so the walk goes on below.
if (
!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX) &&
!(await isTypeOnlyPackage(resolved.packageJson, resolved.packageRoot))
) {
const identity = packageIdentity(resolved.packageJson, resolved.packageRoot);
const existing = collection.byIdentity.get(identity);
if (existing) {
Expand Down
Loading