Release Minimize Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 04:45:12 +02:00
parent 7bd20872a2
commit 87a7ef6ddd
25 changed files with 758 additions and 52 deletions
+296
View File
@@ -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";
}