@@ -0,0 +1,296 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user