import { stableStringify } from "@add-ideas/toolbox-helpers"; import { MAX_TESTS, byteLength, minimizeInput, type FailurePredicate, type InputStructure, type PredicateStability, type ReductionStep, } from "./engine"; export const MAX_BUNDLE_FILES = 100; export const MAX_BUNDLE_BYTES = 4 * 1024 * 1024; export interface BundleFile { path: string; content: string; structure?: InputStructure; } export interface BundleMinimizeOptions { maxTests: number; maxSeconds: number; stability?: PredicateStability; } export type BundlePredicate = ( files: readonly BundleFile[], signal: AbortSignal, ) => boolean | Promise; export interface BundleReductionStep { stage: "files" | "content"; path?: string; beforeFiles: number; afterFiles: number; beforeBytes: number; afterBytes: number; test: number; contentStep?: ReductionStep["stage"]; } export interface BundleMinimizeResult { version: "bundle-ddmin-v1"; original: BundleFile[]; minimized: BundleFile[]; tests: number; accepted: number; exhausted: boolean; stability: PredicateStability; unstableCandidates: number; steps: BundleReductionStep[]; } export interface BundleAdapterProfile { id: "json-map" | "concatenated" | "primary-file"; label: string; description: string; adapt(files: readonly BundleFile[]): string; } export const BUNDLE_ADAPTER_PROFILES: readonly BundleAdapterProfile[] = [ { id: "json-map", label: "JSON path map", description: "Presents a deterministic path-to-content JSON object.", adapt: (files) => stableStringify( Object.fromEntries(files.map((file) => [file.path, file.content])), ), }, { id: "concatenated", label: "Delimited concatenation", description: "Presents all files with explicit, deterministic path markers.", adapt: (files) => files .map((file) => `\n--- toolbox-file:${file.path} ---\n${file.content}`) .join(""), }, { id: "primary-file", label: "Primary file", description: "Presents only the lexicographically first retained file.", adapt: (files) => files[0]?.content ?? "", }, ]; export function bundlePredicateAdapter( predicate: FailurePredicate, profileId: BundleAdapterProfile["id"], ): BundlePredicate { const profile = BUNDLE_ADAPTER_PROFILES.find((item) => item.id === profileId); if (!profile) throw new TypeError(`Unknown bundle adapter ${profileId}.`); return (files, signal) => predicate(profile.adapt(files), signal); } export async function minimizeBundle( input: readonly BundleFile[], options: BundleMinimizeOptions, predicate: BundlePredicate, signal: AbortSignal, ): Promise { const original = normalizeBundle(input); if ( !Number.isInteger(options.maxTests) || options.maxTests < 1 || options.maxTests > MAX_TESTS ) throw new RangeError(`Bundle test budget must be 1–${MAX_TESTS}.`); if ( !Number.isFinite(options.maxSeconds) || options.maxSeconds <= 0 || options.maxSeconds > 30 ) throw new RangeError( "Bundle time budget must be above 0 and at most 30 seconds.", ); const stability = options.stability ?? { samples: 1, requiredPasses: 1 }; if ( !Number.isInteger(stability.samples) || stability.samples < 1 || stability.samples > 9 || !Number.isInteger(stability.requiredPasses) || stability.requiredPasses < 1 || stability.requiredPasses > stability.samples ) throw new RangeError("Bundle stability settings are invalid."); const deadline = Date.now() + options.maxSeconds * 1_000; let tests = 0; let accepted = 0; let exhausted = false; let unstableCandidates = 0; let current = original.map((file) => ({ ...file })); const steps: BundleReductionStep[] = []; const evaluate = async (candidate: readonly BundleFile[]) => { let passes = 0; let observations = 0; while (observations < stability.samples) { signal.throwIfAborted(); if (tests >= options.maxTests || Date.now() >= deadline) { exhausted = true; return false; } tests += 1; if (await predicate(candidate, signal)) passes += 1; observations += 1; } if (passes > 0 && passes < observations) unstableCandidates += 1; return passes >= stability.requiredPasses; }; if (!(await evaluate(current))) throw new TypeError( "The original bundle does not stably satisfy the selected predicate.", ); let granularity = 2; while (current.length && !exhausted) { const chunk = Math.ceil(current.length / granularity); let changed = false; for (let start = 0; start < current.length; start += chunk) { const candidate = current .slice(0, start) .concat(current.slice(start + chunk)); if (await evaluate(candidate)) { const before = bundleBytes(current); const beforeFiles = current.length; current = candidate; accepted += 1; steps.push({ stage: "files", beforeFiles, afterFiles: current.length, beforeBytes: before, afterBytes: bundleBytes(current), test: tests, }); granularity = Math.max(2, granularity - 1); changed = true; break; } if (exhausted) break; } if (!changed) { if (granularity >= current.length) break; granularity = Math.min(current.length, granularity * 2); } } for (let index = 0; index < current.length && !exhausted; index += 1) { const remainingTests = options.maxTests - tests; const remainingSeconds = (deadline - Date.now()) / 1_000; if (remainingTests < 1 || remainingSeconds <= 0) { exhausted = true; break; } const file = current[index]!; const result = await minimizeInput( file.content, { structure: file.structure ?? inferStructure(file.path), maxTests: remainingTests, maxSeconds: Math.min(30, remainingSeconds), stability, }, (candidate, childSignal) => predicate( current.map((entry, candidateIndex) => candidateIndex === index ? { ...entry, content: candidate } : entry, ), childSignal, ), signal, ); tests += result.tests; exhausted ||= result.exhausted; if (result.minimized !== file.content) { const before = bundleBytes(current); current[index] = { ...file, content: result.minimized }; accepted += result.accepted; for (const contentStep of result.steps) steps.push({ stage: "content", path: file.path, beforeFiles: current.length, afterFiles: current.length, beforeBytes: before, afterBytes: bundleBytes(current), test: tests - result.tests + contentStep.test, contentStep: contentStep.stage, }); } } return { version: "bundle-ddmin-v1", original, minimized: current, tests, accepted, exhausted, stability, unstableCandidates, steps, }; } function normalizeBundle(input: readonly BundleFile[]): BundleFile[] { if (!input.length) throw new TypeError("A bundle needs at least one file."); if (input.length > MAX_BUNDLE_FILES) throw new RangeError(`Bundles are limited to ${MAX_BUNDLE_FILES} files.`); const paths = new Set(); const result = input.map((file) => { const path = normalizePath(file.path); if (paths.has(path)) throw new TypeError(`Duplicate bundle path ${path}.`); paths.add(path); if (byteLength(file.content) > 2 * 1024 * 1024) throw new RangeError(`${path} exceeds the 2 MiB per-file limit.`); return { path, content: file.content, ...(file.structure ? { structure: file.structure } : {}), }; }); result.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0, ); if (bundleBytes(result) > MAX_BUNDLE_BYTES) throw new RangeError("Bundle contents exceed 4 MiB."); return result; } function normalizePath(path: string): string { const normalized = path.normalize("NFC"); if ( !normalized || normalized.includes("\\") || normalized.startsWith("/") || normalized.split("/").some((part) => !part || part === "." || part === "..") ) throw new TypeError(`Unsafe bundle path ${path}.`); return normalized; } function bundleBytes(files: readonly BundleFile[]): number { return files.reduce((sum, file) => sum + byteLength(file.content), 0); } function inferStructure(path: string): InputStructure { const lower = path.toLowerCase(); return lower.endsWith(".json") ? "json" : lower.endsWith(".xml") ? "xml" : "text"; }