Files
minimize-tools/src/minimize/bundle.ts
T
zemion 87a7ef6ddd
Verify / verify (push) Canceled after 0s
Release Minimize Tools 0.2.0
2026-09-02 04:45:12 +02:00

297 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<boolean>;
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<BundleMinimizeResult> {
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<string>();
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";
}