Skip to content
Closed

test #18

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
9 changes: 9 additions & 0 deletions .github/workflows/tests-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ on:
push:
branches: [main]
workflow_dispatch:
inputs:
debug:
description: "Enable OptiPrune debug output and unsilence test console logs"
required: false
default: false
type: boolean

jobs:
test:
Expand All @@ -14,6 +20,9 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [20, 22]
name: Validate (${{ matrix.os }}, Node ${{ matrix.node-version }})
env:
OPTIPRUNE_DEBUG: ${{ inputs.debug && '1' || '0' }}
OPTIPRUNE_TEST_QUIET: ${{ inputs.debug && '0' || '1' }}
steps:
- uses: actions/checkout@v4

Expand Down
6 changes: 5 additions & 1 deletion core/src/fs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,11 @@ export interface PackageScriptTarget {
requestedPath: string;
exists: boolean;
}

/**
* Finds source files named by a script entry, such as esbuild's
* `entryPoints: ["client.js"]`. These files are runtime inputs to the script
* even though they are not imported with an ES module edge.
*/
async function sourceCandidatesForScript(
rootDir: string,
relativePath: string,
Expand Down
105 changes: 105 additions & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,100 @@ function isProductionExcludedEntry(relativePath: string): boolean {
return matchesAnyGlob(relativePath, compiledProductionEntryNegations);
}

/**
* Finds local files supplied to bundler APIs as entryPoints. Restricting this
* to an object property named `entryPoints` avoids promoting arbitrary string
* literals such as output filenames or documentation to roots.
*/
function discoverScriptEntryPointReferences(
rootDir: string,
scriptTargets: Awaited<ReturnType<typeof discoverPackageScriptTargets>>,
sourceFiles: string[],
modules: Map<string, ModuleRecord>,
): string[] {
const knownFiles = new Set(sourceFiles.map((file) => normalizeAbsolute(file)));
const referenced = new Set<string>();
const sourceExtensions = new Set(DEFAULT_EXTENSIONS);
const stringValue = (node: any): string | undefined => {
if (node?.type === "TemplateLiteral" && (node.expressions?.length ?? 0) === 0) {
return node.quasis?.[0]?.value?.cooked ?? node.quasis?.[0]?.value?.raw;
}
if (node?.type === "Literal" || node?.type === "StringLiteral") {
return typeof node.value === "string" ? node.value : undefined;
}
return undefined;
};
for (const target of scriptTargets) {
if (!target.exists) continue;
const scriptPath = normalizeAbsolute(path.resolve(rootDir, target.relativePath));
const module = modules.get(scriptPath);
if (!module?.ast || module.parseStatus !== "parsed") continue;
if (
!/\b(?:build|bundle|esbuild|rollup|webpack|rspack|vite|tsup|tsdown|parcel|swc|bun)\b/i.test(
module.sourceText,
)
) {
continue;
}
const staticVariables = new Map<string, any>();
const program =
(module.ast as any)?.type === "Program" ? module.ast : (module.ast as any)?.program;
for (const statement of program?.body ?? []) {
if (statement.type !== "VariableDeclaration") continue;
for (const declaration of statement.declarations ?? []) {
if (declaration.id?.type === "Identifier" && declaration.init) {
staticVariables.set(declaration.id.name, declaration.init);
}
}
}
const collectStrings = (node: any, seen = new Set<string>()): string[] => {
if (!node) return [];
const literal = stringValue(node);
if (literal !== undefined) return [literal];
if (node.type === "Identifier") {
if (seen.has(node.name)) return [];
const init = staticVariables.get(node.name);
if (!init) return [];
const nextSeen = new Set(seen);
nextSeen.add(node.name);
return collectStrings(init, nextSeen);
}
if (node.type === "ArrayExpression") {
return node.elements?.flatMap((element: any) => collectStrings(element, seen)) ?? [];
}
if (node.type === "ObjectExpression") {
return (
node.properties?.flatMap((property: any) =>
property.type === "Property" && !property.computed
? collectStrings(property.value, seen)
: [],
) ?? []
);
}
return [];
};
walkAst(module.ast, (rawNode) => {
const node = rawNode as any;
if (node.type !== "Property" || node.computed) return;
const propertyName = node.key?.name ?? node.key?.value;
if (propertyName !== "entryPoints") return;
for (const requested of collectStrings(node.value)) {
const normalizedRequested = requested.replace(/\\/g, "/").split(/[?#]/, 1)[0];
if (!normalizedRequested || !sourceExtensions.has(path.extname(normalizedRequested))) {
continue;
}
const candidates = [
normalizeAbsolute(path.resolve(path.dirname(scriptPath), normalizedRequested)),
normalizeAbsolute(path.resolve(rootDir, normalizedRequested)),
];
const resolved = candidates.find((candidate) => knownFiles.has(candidate));
if (resolved) referenced.add(resolved);
}
});
}
return [...referenced];
}

/**
* Applies only the configuration fields that have package-local semantics. The
* root configuration remains the source of global analyzer options; workspace
Expand Down Expand Up @@ -612,6 +706,17 @@ export async function analyze(options: AnalyzerOptions): Promise<AnalysisReport>
entryPoints.add(path.normalize(scriptFile));
}
}
// Build tools often receive local source files as string arguments rather
// than importing them (for example esbuild's `entryPoints`). Treat those
// files as script-owned roots so their dependencies remain live.
for (const referencedFile of discoverScriptEntryPointReferences(
baseDir,
scriptTargets,
allSourceFiles,
modules,
)) {
entryPoints.add(path.normalize(referencedFile));
}

for (const binPattern of binEntries) {
const adjustedPattern =
Expand Down
28 changes: 27 additions & 1 deletion core/src/layer6.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "pathe";
import { parseWithYukuBackend, walkAst } from "./parser.js";
import * as yaml from "js-yaml";
import { readJsonFile } from "./fs-utils.js";
import { discoverPackageScriptTargets, normalizeAbsolute, readJsonFile } from "./fs-utils.js";
import type { AnalysisContext, Finding, ModuleRecord } from "./types.js";

export interface DtsExportGraph {
Expand Down Expand Up @@ -418,6 +418,11 @@ export async function analyzeLayer6(context: AnalysisContext): Promise<Finding[]
const globalImports = new Set<string>();
const reachableGlobalImports = new Set<string>();
const workspacePackageNames = new Set<string>(context.options.monorepo?.packageMap.keys() ?? []);
const rootToolingEntryPoints = new Set(
(await discoverPackageScriptTargets(projectRoot))
.filter((target) => target.exists)
.map((target) => normalizeAbsolute(path.resolve(projectRoot, target.relativePath))),
);

const projectHasTypesNode = Array.from(context.modules.keys()).some((f) => {
const normalized = f.replace(/\\/g, "/");
Expand Down Expand Up @@ -608,6 +613,17 @@ export async function analyzeLayer6(context: AnalysisContext): Promise<Finding[]
}
}

const rootToolingProviders = new Map<string, Set<string>>();
for (const [dependency, files] of packageImportFiles.get("root") ?? []) {
if ([...files].some((fileId) => rootToolingEntryPoints.has(fileId))) {
const providers = new Set<string>();
for (const [name, pkg] of context.options.monorepo?.packageMap ?? []) {
if (pkg.allDependencies.has(dependency)) providers.add(name);
}
rootToolingProviders.set(dependency, providers);
}
}

const manifestPaths = new Map<string, string>();
manifestPaths.set("root", path.join(projectRoot, "package.json"));
if (context.options.monorepo) {
Expand Down Expand Up @@ -975,6 +991,14 @@ export async function analyzeLayer6(context: AnalysisContext): Promise<Finding[]
if (
!allDeclaredDeps.has(imp) &&
!workspacePackageNames.has(imp) &&
// Root-level tooling in a monorepo may intentionally use a
// dependency owned by a workspace package, such as a root
// self-test invoking esbuild from the core package.
!(
isRootMonorepoManifest &&
workspaceDeclares(imp) &&
importFilesFor(imp).every((fileId) => rootToolingEntryPoints.has(fileId))
) &&
!imp.startsWith(".") &&
!imp.startsWith("/") &&
!imp.includes(":") &&
Expand Down Expand Up @@ -1124,6 +1148,7 @@ export async function analyzeLayer6(context: AnalysisContext): Promise<Finding[]
isMarkedUsed ||
hasReachableImport(dep) ||
hasPhantomUsage(dep) ||
rootToolingProviders.get(dep)?.has(pkgName) ||
(dep === "typescript" && workspaceHasTypeScriptSources) ||
scriptUsages.has(dep) ||
scriptPackages.has(dep) ||
Expand Down Expand Up @@ -1204,6 +1229,7 @@ export async function analyzeLayer6(context: AnalysisContext): Promise<Finding[]
isMarkedUsed ||
hasReachableImport(dep) ||
hasPhantomUsage(dep) ||
rootToolingProviders.get(dep)?.has(pkgName) ||
(isRootMonorepoManifest && reachableGlobalImports.has(dep)) ||
(dep === "typescript" && workspaceHasTypeScriptSources) ||
scriptUsages.has(dep) ||
Expand Down
112 changes: 112 additions & 0 deletions core/tests/Plugins/package-script-entry-points.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,118 @@ describe("package script entry points", () => {
expect(report.findings.some((finding) => finding.rule === "missing-script-target")).toBe(false);
});

it("retains files bundled by a script and accepts workspace-owned root tooling dependencies", async () => {
await fs.mkdir(path.join(fixtureRoot, "scripts"), { recursive: true });
await fs.mkdir(path.join(fixtureRoot, "packages", "core"), { recursive: true });
await fs.mkdir(path.join(fixtureRoot, "src"), { recursive: true });
await fs.writeFile(
path.join(fixtureRoot, "package.json"),
JSON.stringify(
{
name: "script-bundled-monorepo",
private: true,
workspaces: ["packages/*"],
type: "module",
scripts: {
check: "node scripts/run.mjs",
metadata: "node scripts/metadata.mjs",
},
devDependencies: { "@webcontainer/api": "1.0.0" },
},
null,
2,
),
);
await fs.writeFile(
path.join(fixtureRoot, "packages", "core", "package.json"),
JSON.stringify({ name: "@fixture/core", devDependencies: { esbuild: "1.0.0" } }),
);
await fs.writeFile(
path.join(fixtureRoot, "scripts", "run.mjs"),
'import { build } from "esbuild";\nconst options = { entryPoints: { "bundle.min.js": `client.js?worker` } };\nbuild(options);\n',
);
await fs.writeFile(
path.join(fixtureRoot, "scripts", "metadata.mjs"),
'const metadata = { entryPoints: ["false-positive.js"] };\nvoid metadata;\n',
);
await fs.writeFile(path.join(fixtureRoot, "scripts", "false-positive.js"), "");
await fs.writeFile(
path.join(fixtureRoot, "scripts", "client.js"),
'import { WebContainer } from "@webcontainer/api";\nvoid WebContainer;\n',
);
await fs.writeFile(path.join(fixtureRoot, "scripts", "bundle.min.js"), "");
const report = await analyze({
rootDir: fixtureRoot,
entry: [],
extensions: [".js", ".mjs"],
includeConventionalEntries: false,
reportUnusedExports: false,
layers: { skip3: true, skip4: true },
});
expect(report.entryPoints).toContain("scripts/client.js");
expect(report.entryPoints).not.toContain("scripts/false-positive.js");
expect(
report.findings.some(
(finding) =>
(finding.rule === "unused-dev-dependency" || finding.rule === "unused-dependency") &&
finding.evidence?.package === "@webcontainer/api",
),
).toBe(false);
expect(
report.findings.some(
(finding) =>
(finding.rule === "missing-dependency" || finding.rule === "missing-dev-dependency") &&
finding.evidence?.package === "esbuild",
),
).toBe(false);
expect(
report.findings.some(
(finding) =>
(finding.rule === "unused-dev-dependency" || finding.rule === "unused-dependency") &&
finding.evidence?.package === "esbuild",
),
).toBe(false);
});

it("reports a workspace-owned dependency imported by root production code", async () => {
await fs.mkdir(path.join(fixtureRoot, "packages", "core"), { recursive: true });
await fs.mkdir(path.join(fixtureRoot, "src"), { recursive: true });
await fs.writeFile(
path.join(fixtureRoot, "package.json"),
JSON.stringify({
name: "root-production-import",
private: true,
workspaces: ["packages/*"],
type: "module",
}),
);
await fs.writeFile(
path.join(fixtureRoot, "packages", "core", "package.json"),
JSON.stringify({ name: "@fixture/core", devDependencies: { esbuild: "1.0.0" } }),
);
await fs.writeFile(
path.join(fixtureRoot, "src", "root.mjs"),
'import { build } from "esbuild";\nvoid build;\n',
);

const report = await analyze({
rootDir: fixtureRoot,
entry: ["src/root.mjs"],
extensions: [".mjs"],
includeConventionalEntries: false,
reportUnusedExports: false,
layers: { skip3: true, skip4: true },
});

expect(
report.findings.some(
(finding) =>
(finding.rule === "missing-dependency" || finding.rule === "missing-dev-dependency") &&
finding.evidence?.package === "esbuild",
),
).toBe(true);
});

it("treats Bun file arguments as reachable roots without treating Bun subcommands as files", async () => {
await fs.mkdir(path.join(fixtureRoot, "perf"), { recursive: true });
await fs.mkdir(path.join(fixtureRoot, "test", "integration"), { recursive: true });
Expand Down
Loading