297 lines
8.6 KiB
TypeScript
297 lines
8.6 KiB
TypeScript
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";
|
||
}
|