From 11a9ceab10a1980b21824113c13c754a10c48b24 Mon Sep 17 00:00:00 2001 From: DreamLongYT Date: Sun, 25 Oct 2026 10:06:01 +0100 Subject: [PATCH 1/2] test --- core/src/fs-utils.ts | 6 +- core/src/index.ts | 105 ++++++++++++++++ core/src/layer6.ts | 28 ++++- .../package-script-entry-points.test.ts | 112 ++++++++++++++++++ 4 files changed, 249 insertions(+), 2 deletions(-) diff --git a/core/src/fs-utils.ts b/core/src/fs-utils.ts index e9c837b8..0f574cd9 100644 --- a/core/src/fs-utils.ts +++ b/core/src/fs-utils.ts @@ -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, diff --git a/core/src/index.ts b/core/src/index.ts index e24f6e99..db83987d 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -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>, + sourceFiles: string[], + modules: Map, +): string[] { + const knownFiles = new Set(sourceFiles.map((file) => normalizeAbsolute(file))); + const referenced = new Set(); + 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(); + 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[] => { + 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 @@ -612,6 +706,17 @@ export async function analyze(options: AnalyzerOptions): Promise 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 = diff --git a/core/src/layer6.ts b/core/src/layer6.ts index 49db8a87..7c8e29ca 100644 --- a/core/src/layer6.ts +++ b/core/src/layer6.ts @@ -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 { @@ -418,6 +418,11 @@ export async function analyzeLayer6(context: AnalysisContext): Promise(); const reachableGlobalImports = new Set(); const workspacePackageNames = new Set(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, "/"); @@ -608,6 +613,17 @@ export async function analyzeLayer6(context: AnalysisContext): Promise>(); + for (const [dependency, files] of packageImportFiles.get("root") ?? []) { + if ([...files].some((fileId) => rootToolingEntryPoints.has(fileId))) { + const providers = new Set(); + 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(); manifestPaths.set("root", path.join(projectRoot, "package.json")); if (context.options.monorepo) { @@ -975,6 +991,14 @@ export async function analyzeLayer6(context: AnalysisContext): Promise rootToolingEntryPoints.has(fileId)) + ) && !imp.startsWith(".") && !imp.startsWith("/") && !imp.includes(":") && @@ -1124,6 +1148,7 @@ export async function analyzeLayer6(context: AnalysisContext): Promise { 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 }); From fc3aac5a631948388dc9cb3c83fe2e268f1a7201 Mon Sep 17 00:00:00 2001 From: DreamLongYT Date: Sun, 25 Oct 2026 15:21:07 +0100 Subject: [PATCH 2/2] Add debug --- .github/workflows/tests-push.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/tests-push.yml b/.github/workflows/tests-push.yml index 5efeceb8..1339f5a7 100644 --- a/.github/workflows/tests-push.yml +++ b/.github/workflows/tests-push.yml @@ -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: @@ -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