Release Minimize Tools 0.1.0

This commit is contained in:
2026-09-01 13:22:35 +02:00
commit 7bd20872a2
59 changed files with 9534 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing Minimize Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>Minimize Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About Minimize Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>
Repeatedly remove structure, lines, tokens, and characters while proving
that your selected failure predicate still holds.
</p>
<p>
Inputs and predicates stay on this device. Test and time budgets are
hard limits; regular expressions run in a disposable worker. A small
result proves only the chosen predicate, so preserve its failure
signature whenever the option is available.
</p>
</dialog>
);
}
+596
View File
@@ -0,0 +1,596 @@
import { useMemo, useRef, useState, type ChangeEvent } from "react";
import {
stableStringify,
triggerBlobDownload,
} from "@add-ideas/toolbox-helpers";
import {
byteLength,
minimizeInput,
type InputStructure,
type MinimizeProgress,
type MinimizeResult,
} from "../minimize/engine";
import {
createPredicate,
type PredicateKind,
type PredicateRecipe,
type RegexEvaluator,
type RegexOutcome,
} from "../minimize/predicates";
const JSON_SAMPLE = `{
"request": {
"id": 0,
"label": "keep the same failing constraint"
},
"unrelated": [1, 2, 3],
"debug": true
}`;
const JSON_SCHEMA = `{
"type": "object",
"required": ["request"],
"properties": {
"request": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer", "minimum": 1 }
}
}
}
}`;
const XML_SAMPLE = `<case>
<metadata removable="yes"><author>local</author></metadata>
<payload><message>BOOM</message><noise>discard me</noise></payload>
</case>`;
const XSLT_SAMPLE = `<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="text"/>
<xsl:template match="/">
<xsl:if test="//message = 'BOOM'">
<xsl:message terminate="yes">reproduced</xsl:message>
</xsl:if>
<xsl:text>ok</xsl:text>
</xsl:template>
</xsl:stylesheet>`;
interface WorkerReply {
id: number;
matched?: boolean;
elapsedMs?: number;
error?: string;
}
class RegexRunner {
private worker?: Worker;
private sequence = 0;
private open(): Worker {
this.worker ??= new Worker(
new URL("../minimize/regex.worker.ts", import.meta.url),
{ type: "module" },
);
return this.worker;
}
close(): void {
this.worker?.terminate();
this.worker = undefined;
}
readonly evaluate: RegexEvaluator = (
candidate,
pattern,
flags,
thresholdMs,
signal,
) =>
new Promise<RegexOutcome>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Minimization was cancelled.", "AbortError"));
return;
}
const worker = this.open();
const id = ++this.sequence;
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal.removeEventListener("abort", abort);
callback();
};
const abort = () => {
this.close();
finish(() =>
reject(new DOMException("Minimization was cancelled.", "AbortError")),
);
};
const safetyMs = Math.min(1_000, Math.max(100, thresholdMs * 4));
const timer = window.setTimeout(() => {
this.close();
finish(() =>
resolve({ matched: false, elapsedMs: thresholdMs, timedOut: true }),
);
}, safetyMs);
worker.onmessage = (event: MessageEvent<WorkerReply>) => {
if (event.data.id !== id) return;
finish(() => {
if (event.data.error) reject(new SyntaxError(event.data.error));
else
resolve({
matched: event.data.matched === true,
elapsedMs: event.data.elapsedMs ?? 0,
timedOut: false,
});
});
};
worker.onerror = () => {
this.close();
finish(() => reject(new Error("The isolated regex worker failed.")));
};
signal.addEventListener("abort", abort, { once: true });
worker.postMessage({ id, candidate, pattern, flags });
});
}
function download(text: string, filename: string, mediaType: string) {
triggerBlobDownload(new Blob([text], { type: mediaType }), filename);
}
function extension(structure: InputStructure): string {
return structure === "json" ? "json" : structure === "xml" ? "xml" : "txt";
}
export function Workbench() {
const [source, setSource] = useState(JSON_SAMPLE);
const [sourceName, setSourceName] = useState("schema-failure.json");
const [structure, setStructure] = useState<InputStructure>("json");
const [predicateKind, setPredicateKind] =
useState<PredicateKind>("json-schema-fails");
const [needle, setNeedle] = useState("BOOM");
const [pattern, setPattern] = useState("^(a+)+$");
const [flags, setFlags] = useState("u");
const [thresholdMs, setThresholdMs] = useState(25);
const [schema, setSchema] = useState(JSON_SCHEMA);
const [stylesheet, setStylesheet] = useState(XSLT_SAMPLE);
const [preserveSignature, setPreserveSignature] = useState(true);
const [maxTests, setMaxTests] = useState(500);
const [maxSeconds, setMaxSeconds] = useState(10);
const [result, setResult] = useState<MinimizeResult>();
const [resultRecipe, setResultRecipe] = useState<PredicateRecipe>();
const [resultLimits, setResultLimits] = useState<{
maxTests: number;
maxSeconds: number;
}>();
const [resultStructure, setResultStructure] =
useState<InputStructure>("text");
const [progress, setProgress] = useState<MinimizeProgress>();
const [running, setRunning] = useState(false);
const [error, setError] = useState<string>();
const controller = useRef<AbortController | undefined>(undefined);
const regexRunner = useRef<RegexRunner | undefined>(undefined);
const recipe: PredicateRecipe = {
kind: predicateKind,
needle,
pattern,
flags,
thresholdMs,
schema,
stylesheet,
preserveFailureSignature: preserveSignature,
};
const report = useMemo(
() =>
result
? `${stableStringify(
{
minimizer: result.version,
structure: resultStructure,
predicate: resultRecipe,
limits: resultLimits,
originalBytes: byteLength(result.original),
minimizedBytes: byteLength(result.minimized),
tests: result.tests,
accepted: result.accepted,
exhausted: result.exhausted,
steps: result.steps,
},
2,
{ maxTextChars: 2 * 1024 * 1024, maxDepth: 16, maxNodes: 20_000 },
)}\n`
: "",
[result, resultLimits, resultRecipe, resultStructure],
);
const run = async () => {
controller.current?.abort();
regexRunner.current?.close();
const nextController = new AbortController();
const runner = new RegexRunner();
controller.current = nextController;
regexRunner.current = runner;
setRunning(true);
setProgress(undefined);
setError(undefined);
try {
const predicate = createPredicate(recipe, runner.evaluate);
const next = await minimizeInput(
source,
{
structure,
maxTests:
predicateKind === "xslt-throws" ||
predicateKind === "xslt-output-contains"
? Math.min(maxTests, 200)
: maxTests,
maxSeconds,
},
predicate,
nextController.signal,
setProgress,
);
setResult(next);
setResultRecipe(structuredClone(recipe));
setResultLimits({
maxTests:
predicateKind === "xslt-throws" ||
predicateKind === "xslt-output-contains"
? Math.min(maxTests, 200)
: maxTests,
maxSeconds,
});
setResultStructure(structure);
} catch (reason) {
if (!(reason instanceof DOMException && reason.name === "AbortError"))
setError(
reason instanceof Error ? reason.message : "Minimization failed.",
);
} finally {
runner.close();
if (controller.current === nextController) {
setRunning(false);
controller.current = undefined;
}
}
};
const cancel = () => controller.current?.abort();
const openFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setError("Input file exceeds 2 MiB.");
return;
}
setSource(await file.text());
setSourceName(file.name);
setStructure(
file.name.toLowerCase().endsWith(".json")
? "json"
: file.name.toLowerCase().endsWith(".xml")
? "xml"
: "text",
);
setError(undefined);
};
const applyPreset = (name: "json" | "xml" | "regex") => {
if (name === "json") {
setSource(JSON_SAMPLE);
setSourceName("schema-failure.json");
setStructure("json");
setPredicateKind("json-schema-fails");
setSchema(JSON_SCHEMA);
} else if (name === "xml") {
setSource(XML_SAMPLE);
setSourceName("xslt-case.xml");
setStructure("xml");
setPredicateKind("xslt-throws");
setStylesheet(XSLT_SAMPLE);
} else {
setSource(`${"a".repeat(5_000)}!`);
setSourceName("regex-case.txt");
setStructure("text");
setPredicateKind("regex-slow");
setPattern("^(a+)+$");
setFlags("u");
setThresholdMs(25);
}
setError(undefined);
};
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Failure-preserving delta debugging</p>
<h1>Minimize Tools</h1>
<p>
Reduce a local reproducer while repeatedly proving that the same
selected failure remains.
</p>
</div>
<span className="privacy-pill">Bounded · cancellable · local</span>
</section>
<section className="preset-row" aria-label="Example minimizations">
<button type="button" onClick={() => applyPreset("json")}>
JSON Schema failure
</button>
<button type="button" onClick={() => applyPreset("xml")}>
XSLT failure
</button>
<button type="button" onClick={() => applyPreset("regex")}>
Slow regex input
</button>
</section>
<section className="work-grid">
<article className="panel input-panel">
<div className="panel-heading">
<div>
<h2>Failing input</h2>
<p>
{sourceName} · {byteLength(source).toLocaleString()} bytes
</p>
</div>
<label className="button file-button">
Open file
<input
type="file"
data-testid="source-file"
onChange={(event) => void openFile(event)}
/>
</label>
</div>
<label>
Structure
<select
value={structure}
onChange={(event) =>
setStructure(event.target.value as InputStructure)
}
>
<option value="text">Text / lines</option>
<option value="json">JSON</option>
<option value="xml">XML</option>
</select>
</label>
<textarea
aria-label="Failing input"
value={source}
onChange={(event) => setSource(event.target.value)}
rows={22}
spellCheck={false}
/>
</article>
<article className="panel predicate-panel">
<div className="panel-heading">
<div>
<h2>Failure predicate</h2>
<p>The original must satisfy this before any reduction begins.</p>
</div>
</div>
<label>
Predicate
<select
aria-label="Failure predicate"
value={predicateKind}
onChange={(event) =>
setPredicateKind(event.target.value as PredicateKind)
}
>
<option value="contains">Input contains marker</option>
<option value="json-schema-fails">
Focused JSON Schema fails
</option>
<option value="invalid-json">Input remains invalid JSON</option>
<option value="invalid-xml">Input remains invalid XML</option>
<option value="regex-matches">Regex still matches</option>
<option value="regex-slow">Regex reaches time threshold</option>
<option value="xslt-throws">Safe local XSLT throws</option>
<option value="xslt-output-contains">
XSLT output contains marker
</option>
</select>
</label>
{predicateKind === "contains" ||
predicateKind === "xslt-output-contains" ? (
<label>
Marker
<input
value={needle}
onChange={(event) => setNeedle(event.target.value)}
/>
</label>
) : null}
{predicateKind === "json-schema-fails" ? (
<>
<label className="check-row">
<input
type="checkbox"
checked={preserveSignature}
onChange={(event) =>
setPreserveSignature(event.target.checked)
}
/>
Preserve the first failing path and rule
</label>
<label>
Focused JSON Schema
<textarea
aria-label="JSON Schema"
value={schema}
onChange={(event) => setSchema(event.target.value)}
rows={14}
spellCheck={false}
/>
</label>
</>
) : null}
{predicateKind === "regex-matches" ||
predicateKind === "regex-slow" ? (
<div className="compact-grid">
<label>
Regular expression
<input
value={pattern}
onChange={(event) => setPattern(event.target.value)}
/>
</label>
<label>
Flags
<input
value={flags}
maxLength={8}
onChange={(event) => setFlags(event.target.value)}
/>
</label>
<label>
Slow threshold (ms; worker safety limit 1 s)
<input
type="number"
min="1"
max="500"
value={thresholdMs}
onChange={(event) =>
setThresholdMs(Number(event.target.value))
}
/>
</label>
</div>
) : null}
{predicateKind === "xslt-throws" ||
predicateKind === "xslt-output-contains" ? (
<label>
Local XSLT (imports, includes, external resource functions and
hrefs are rejected)
<textarea
aria-label="Local XSLT"
value={stylesheet}
onChange={(event) => setStylesheet(event.target.value)}
rows={14}
spellCheck={false}
/>
</label>
) : null}
<div className="compact-grid limits">
<label>
Maximum predicate tests
<input
type="number"
min="1"
max="2000"
value={maxTests}
onChange={(event) => setMaxTests(Number(event.target.value))}
/>
</label>
<label>
Maximum seconds
<input
type="number"
min="1"
max="30"
value={maxSeconds}
onChange={(event) => setMaxSeconds(Number(event.target.value))}
/>
</label>
</div>
<div className="action-row">
<button
className="primary-button"
type="button"
disabled={running}
onClick={() => void run()}
>
{running ? "Minimizing…" : "Minimize reproducer"}
</button>
<button type="button" disabled={!running} onClick={cancel}>
Cancel
</button>
</div>
{running && progress ? (
<p className="running" role="status">
{progress.stage} · {progress.tests} tests · {progress.accepted}{" "}
accepted · {byteLength(progress.candidate).toLocaleString()} bytes
</p>
) : null}
{error ? (
<p className="error" role="alert">
{error} The previous result was retained.
</p>
) : null}
</article>
</section>
<section className="panel result-panel" aria-live="polite">
<div className="panel-heading">
<div>
<h2>Smallest reproduced case</h2>
<p>
{result
? `${byteLength(result.original).toLocaleString()}${byteLength(result.minimized).toLocaleString()} bytes · ${result.tests} tests · ${result.accepted} accepted${result.exhausted ? " · budget reached" : ""}`
: "Run a verified predicate to produce a result."}
</p>
</div>
{result ? (
<div className="action-row">
<button
type="button"
onClick={() =>
download(
result.minimized,
`minimized.${extension(resultStructure)}`,
"text/plain;charset=utf-8",
)
}
>
Download case
</button>
<button
type="button"
onClick={() =>
download(
report,
"minimization-report.json",
"application/json",
)
}
>
Download report
</button>
</div>
) : null}
</div>
<textarea
aria-label="Minimized result"
readOnly
value={result?.minimized ?? ""}
rows={16}
spellCheck={false}
/>
{result ? (
<details>
<summary>Accepted reductions ({result.steps.length})</summary>
<ol className="step-list">
{result.steps.map((step, index) => (
<li key={`${step.test}-${index}`}>
{step.stage}: {step.beforeBytes.toLocaleString()} {" "}
{step.afterBytes.toLocaleString()} bytes (test {step.test})
</li>
))}
</ol>
</details>
) : null}
</section>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+411
View File
@@ -0,0 +1,411 @@
import { safeJsonParse } from "@add-ideas/toolbox-helpers";
export const MINIMIZER_VERSION = "structured-ddmin-v1";
export const MAX_INPUT_BYTES = 2 * 1024 * 1024;
export const MAX_TESTS = 2_000;
export const MAX_SECONDS = 30;
export type InputStructure = "text" | "json" | "xml";
export interface MinimizeOptions {
structure: InputStructure;
maxTests: number;
maxSeconds: number;
}
export interface ReductionStep {
stage: "normalize" | "structure" | "lines" | "tokens" | "characters";
beforeBytes: number;
afterBytes: number;
test: number;
}
export interface MinimizeProgress {
candidate: string;
tests: number;
accepted: number;
stage: ReductionStep["stage"];
}
export interface MinimizeResult {
version: typeof MINIMIZER_VERSION;
original: string;
minimized: string;
tests: number;
accepted: number;
exhausted: boolean;
steps: ReductionStep[];
}
export type FailurePredicate = (
candidate: string,
signal: AbortSignal,
) => boolean | Promise<boolean>;
const encoder = new TextEncoder();
export function byteLength(value: string): number {
return encoder.encode(value).byteLength;
}
export function assertInput(value: string): string {
if (byteLength(value) > MAX_INPUT_BYTES)
throw new RangeError("Input exceeds the 2 MiB minimization limit.");
if (value.includes("\0")) throw new SyntaxError("Input contains NUL bytes.");
return value;
}
function abortError(message = "Minimization was cancelled."): DOMException {
return new DOMException(message, "AbortError");
}
function assertOptions(options: MinimizeOptions): void {
if (
!Number.isInteger(options.maxTests) ||
options.maxTests < 1 ||
options.maxTests > MAX_TESTS
)
throw new RangeError(
`Test budget must be 1${MAX_TESTS.toLocaleString()}.`,
);
if (
!Number.isFinite(options.maxSeconds) ||
options.maxSeconds <= 0 ||
options.maxSeconds > MAX_SECONDS
)
throw new RangeError(
`Time budget must be above 0 and at most ${MAX_SECONDS} seconds.`,
);
}
function splitLines(value: string): string[] {
return value.match(/.*?(?:\r\n|\n|\r|$)/gu)?.filter(Boolean) ?? [];
}
function splitTokens(value: string): string[] {
return value.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? [];
}
function jsonText(value: unknown): string {
return JSON.stringify(value);
}
function boundedJson(value: string): unknown {
return safeJsonParse(value, {
maxTextChars: MAX_INPUT_BYTES,
maxDepth: 64,
maxNodes: 100_000,
rejectDangerousKeys: true,
});
}
type JsonPath = Array<string | number>;
function jsonAt(root: unknown, path: JsonPath): unknown {
let value = root;
for (const part of path)
value = Array.isArray(value)
? value[part as number]
: (value as Record<string, unknown>)[part as string];
return value;
}
function jsonVariants(source: string): string[] {
let value: unknown;
try {
value = boundedJson(source);
} catch {
return [];
}
const paths: JsonPath[] = [[]];
for (
let cursor = 0;
cursor < paths.length && paths.length < 1_000;
cursor += 1
) {
const current = jsonAt(value, paths[cursor]!);
if (Array.isArray(current))
current.forEach((_item, index) => paths.push([...paths[cursor]!, index]));
else if (current !== null && typeof current === "object")
Object.keys(current).forEach((key) =>
paths.push([...paths[cursor]!, key]),
);
}
const variants = new Set<string>();
const add = (next: unknown) => {
const text = jsonText(next);
if (byteLength(text) < byteLength(source)) variants.add(text);
};
for (const path of paths) {
if (variants.size >= 1_000) break;
const current = jsonAt(value, path);
if (Array.isArray(current) && current.length) {
for (let groups = 2; groups <= Math.min(current.length, 8); groups *= 2) {
const chunk = Math.ceil(current.length / groups);
for (let start = 0; start < current.length; start += chunk) {
const clone = structuredClone(value);
const target = jsonAt(clone, path) as unknown[];
target.splice(start, chunk);
add(clone);
}
}
} else if (current !== null && typeof current === "object") {
const keys = Object.keys(current);
for (let groups = 2; groups <= Math.min(keys.length, 8); groups *= 2) {
const chunk = Math.ceil(keys.length / groups);
for (let start = 0; start < keys.length; start += chunk) {
const clone = structuredClone(value);
const target = jsonAt(clone, path) as Record<string, unknown>;
keys.slice(start, start + chunk).forEach((key) => delete target[key]);
add(clone);
}
}
}
if (path.length) {
for (const replacement of [null, "", 0, false]) {
const clone = structuredClone(value);
const parent = jsonAt(clone, path.slice(0, -1));
const key = path.at(-1)!;
if (Array.isArray(parent)) parent[key as number] = replacement;
else (parent as Record<string, unknown>)[key as string] = replacement;
add(clone);
}
}
}
return [...variants].sort(
(left, right) => byteLength(left) - byteLength(right),
);
}
function parseXml(value: string): XMLDocument | undefined {
if (/<!DOCTYPE/iu.test(value)) return undefined;
const document = new DOMParser().parseFromString(value, "application/xml");
return document.querySelector("parsererror") ? undefined : document;
}
type NodePath = number[];
function nodeAt(root: Node, path: NodePath): Node | undefined {
let node: Node | undefined = root;
for (const index of path) node = node?.childNodes[index];
return node;
}
function xmlVariants(source: string): string[] {
const document = parseXml(source);
const root = document?.documentElement;
if (!root) return [];
const paths: NodePath[] = [[]];
for (
let cursor = 0;
cursor < paths.length && paths.length < 500;
cursor += 1
) {
const node = nodeAt(root, paths[cursor]!);
if (!node) continue;
for (let index = 0; index < node.childNodes.length; index += 1)
paths.push([...paths[cursor]!, index]);
}
const variants = new Set<string>();
const add = (clone: Element) => {
const text = new XMLSerializer().serializeToString(clone);
if (byteLength(text) < byteLength(source)) variants.add(text);
};
for (const path of paths) {
if (variants.size >= 1_000) break;
const original = nodeAt(root, path);
if (!original) continue;
if (path.length) {
const clone = root.cloneNode(true) as Element;
nodeAt(clone, path)?.parentNode?.removeChild(nodeAt(clone, path)!);
add(clone);
}
if (original.nodeType === Node.ELEMENT_NODE) {
const element = original as Element;
for (const attribute of Array.from(element.attributes)) {
const clone = root.cloneNode(true) as Element;
(nodeAt(clone, path) as Element | undefined)?.removeAttributeNS(
attribute.namespaceURI,
attribute.localName,
);
add(clone);
}
if (element.childNodes.length) {
const clone = root.cloneNode(true) as Element;
(nodeAt(clone, path) as Element).replaceChildren();
add(clone);
}
}
}
return [...variants].sort(
(left, right) => byteLength(left) - byteLength(right),
);
}
export async function minimizeInput(
input: string,
options: MinimizeOptions,
predicate: FailurePredicate,
signal: AbortSignal,
onProgress?: (progress: MinimizeProgress) => void,
): Promise<MinimizeResult> {
assertInput(input);
assertOptions(options);
const started = Date.now();
const deadline = started + options.maxSeconds * 1_000;
let tests = 0;
let accepted = 0;
let exhausted = false;
let current = input;
let stage: ReductionStep["stage"] = "normalize";
const steps: ReductionStep[] = [];
const cache = new Map<string, boolean>();
const preserveValidSyntax =
options.structure === "json"
? (() => {
try {
boundedJson(input);
return true;
} catch {
return false;
}
})()
: options.structure === "xml"
? parseXml(input) !== undefined
: false;
const evaluate = async (candidate: string): Promise<boolean> => {
if (signal.aborted) throw abortError();
if (tests >= options.maxTests || Date.now() >= deadline) {
exhausted = true;
return false;
}
const cached = cache.get(candidate);
if (cached !== undefined) return cached;
tests += 1;
if (
preserveValidSyntax &&
((options.structure === "json" &&
(() => {
try {
boundedJson(candidate);
return false;
} catch {
return true;
}
})()) ||
(options.structure === "xml" && !parseXml(candidate)))
) {
cache.set(candidate, false);
return false;
}
const result = await predicate(candidate, signal);
cache.set(candidate, result);
if (tests % 20 === 0)
onProgress?.({ candidate: current, tests, accepted, stage });
return result;
};
if (!(await evaluate(current)))
throw new TypeError(
"The original input does not satisfy the selected failure predicate.",
);
const accept = async (
candidate: string,
nextStage: ReductionStep["stage"],
): Promise<boolean> => {
if (byteLength(candidate) >= byteLength(current)) return false;
if (!(await evaluate(candidate))) return false;
const beforeBytes = byteLength(current);
current = candidate;
accepted += 1;
stage = nextStage;
steps.push({
stage,
beforeBytes,
afterBytes: byteLength(current),
test: tests,
});
onProgress?.({ candidate: current, tests, accepted, stage });
return true;
};
if (options.structure === "json") {
try {
await accept(jsonText(boundedJson(current)), "normalize");
} catch {
/* the selected predicate may intentionally target invalid JSON */
}
} else if (options.structure === "xml") {
const parsed = parseXml(current);
if (parsed)
await accept(
new XMLSerializer().serializeToString(parsed.documentElement),
"normalize",
);
}
const structuredVariants =
options.structure === "json"
? jsonVariants
: options.structure === "xml"
? xmlVariants
: undefined;
if (structuredVariants) {
stage = "structure";
let changed = true;
while (changed && !exhausted) {
changed = false;
for (const candidate of structuredVariants(current)) {
if (await accept(candidate, "structure")) {
changed = true;
break;
}
if (exhausted) break;
}
}
}
const reduceUnits = async (
split: (value: string) => string[],
nextStage: ReductionStep["stage"],
) => {
stage = nextStage;
let units = split(current);
let granularity = 2;
while (units.length >= 2 && !exhausted) {
const chunk = Math.ceil(units.length / granularity);
let changed = false;
for (let start = 0; start < units.length; start += chunk) {
const kept = units.slice(0, start).concat(units.slice(start + chunk));
const candidate = kept.join("");
if (await accept(candidate, nextStage)) {
units = split(current);
granularity = Math.max(2, granularity - 1);
changed = true;
break;
}
if (exhausted) break;
}
if (!changed) {
if (granularity >= units.length) break;
granularity = Math.min(units.length, granularity * 2);
}
}
};
await reduceUnits(splitLines, "lines");
await reduceUnits(splitTokens, "tokens");
await reduceUnits((value) => Array.from(value), "characters");
onProgress?.({ candidate: current, tests, accepted, stage });
return {
version: MINIMIZER_VERSION,
original: input,
minimized: current,
tests,
accepted,
exhausted,
steps,
};
}
+438
View File
@@ -0,0 +1,438 @@
import { safeJsonParse } from "@add-ideas/toolbox-helpers";
import { MAX_INPUT_BYTES } from "./engine";
export type PredicateKind =
| "contains"
| "json-schema-fails"
| "invalid-json"
| "invalid-xml"
| "regex-matches"
| "regex-slow"
| "xslt-throws"
| "xslt-output-contains";
export interface PredicateRecipe {
kind: PredicateKind;
needle?: string;
pattern?: string;
flags?: string;
thresholdMs?: number;
schema?: string;
stylesheet?: string;
preserveFailureSignature?: boolean;
}
export interface RegexOutcome {
matched: boolean;
elapsedMs: number;
timedOut: boolean;
}
export type RegexEvaluator = (
candidate: string,
pattern: string,
flags: string,
thresholdMs: number,
signal: AbortSignal,
) => Promise<RegexOutcome>;
type JsonSchema = boolean | Record<string, unknown>;
function object(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function deepEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right))
return (
left.length === right.length &&
left.every((item, index) => deepEqual(item, right[index]))
);
const leftObject = object(left);
const rightObject = object(right);
if (!leftObject || !rightObject) return false;
const leftKeys = Object.keys(leftObject).sort();
const rightKeys = Object.keys(rightObject).sort();
return (
leftKeys.length === rightKeys.length &&
leftKeys.every(
(key, index) =>
key === rightKeys[index] &&
deepEqual(leftObject[key], rightObject[key]),
)
);
}
const SUPPORTED_SCHEMA_KEYS = new Set([
"$schema",
"$id",
"title",
"description",
"default",
"examples",
"type",
"enum",
"const",
"required",
"properties",
"additionalProperties",
"items",
"minItems",
"maxItems",
"minLength",
"maxLength",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
]);
function assertSchema(
schema: unknown,
path = "$",
depth = 0,
): asserts schema is JsonSchema {
if (depth > 32) throw new RangeError("JSON Schema exceeds 32 levels.");
if (typeof schema === "boolean") return;
const value = object(schema);
if (!value)
throw new TypeError(`${path} must be a schema object or boolean.`);
for (const key of Object.keys(value))
if (!SUPPORTED_SCHEMA_KEYS.has(key))
throw new TypeError(`${path}: unsupported JSON Schema keyword ${key}.`);
if (value.type !== undefined) {
const types = Array.isArray(value.type) ? value.type : [value.type];
if (
!types.length ||
types.some(
(type) =>
typeof type !== "string" ||
![
"null",
"boolean",
"object",
"array",
"number",
"integer",
"string",
].includes(type),
)
)
throw new TypeError(`${path}.type is unsupported.`);
}
if (
value.required !== undefined &&
(!Array.isArray(value.required) ||
value.required.some((item) => typeof item !== "string"))
)
throw new TypeError(`${path}.required must be an array of strings.`);
if (value.enum !== undefined && !Array.isArray(value.enum))
throw new TypeError(`${path}.enum must be an array.`);
if (
value.additionalProperties !== undefined &&
typeof value.additionalProperties !== "boolean"
)
throw new TypeError(
`${path}.additionalProperties must be true or false in the focused subset.`,
);
for (const key of [
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
] as const)
if (
value[key] !== undefined &&
(typeof value[key] !== "number" || !Number.isFinite(value[key]))
)
throw new TypeError(`${path}.${key} must be a finite number.`);
if (typeof value.multipleOf === "number" && value.multipleOf <= 0)
throw new TypeError(`${path}.multipleOf must be above zero.`);
for (const key of ["minItems", "maxItems", "minLength", "maxLength"] as const)
if (
value[key] !== undefined &&
(typeof value[key] !== "number" ||
!Number.isInteger(value[key]) ||
value[key] < 0)
)
throw new TypeError(`${path}.${key} must be a non-negative integer.`);
for (const [minimum, maximum] of [
["minItems", "maxItems"],
["minLength", "maxLength"],
["minimum", "maximum"],
] as const)
if (
typeof value[minimum] === "number" &&
typeof value[maximum] === "number" &&
value[minimum] > value[maximum]
)
throw new TypeError(`${path}.${minimum} exceeds ${maximum}.`);
if (value.properties !== undefined) {
const properties = object(value.properties);
if (!properties)
throw new TypeError(`${path}.properties must be an object.`);
for (const [key, child] of Object.entries(properties))
assertSchema(child, `${path}.properties.${key}`, depth + 1);
}
if (value.items !== undefined)
assertSchema(value.items, `${path}.items`, depth + 1);
}
function matchesType(value: unknown, type: string): boolean {
if (type === "null") return value === null;
if (type === "array") return Array.isArray(value);
if (type === "object") return object(value) !== undefined;
if (type === "integer")
return typeof value === "number" && Number.isInteger(value);
return typeof value === type;
}
function firstIssue(
value: unknown,
schema: JsonSchema,
path = "$",
depth = 0,
): string | undefined {
if (typeof schema === "boolean")
return schema ? undefined : `${path}:false-schema`;
if (depth > 64) return `${path}:depth`;
if (schema.type !== undefined) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
if (!types.some((type) => matchesType(value, type as string)))
return `${path}:type`;
}
if (
Array.isArray(schema.enum) &&
!schema.enum.some((item) => deepEqual(item, value))
)
return `${path}:enum`;
if (Object.hasOwn(schema, "const") && !deepEqual(schema.const, value))
return `${path}:const`;
if (typeof value === "string") {
if (
typeof schema.minLength === "number" &&
Array.from(value).length < schema.minLength
)
return `${path}:minLength`;
if (
typeof schema.maxLength === "number" &&
Array.from(value).length > schema.maxLength
)
return `${path}:maxLength`;
}
if (typeof value === "number" && Number.isFinite(value)) {
if (typeof schema.minimum === "number" && value < schema.minimum)
return `${path}:minimum`;
if (typeof schema.maximum === "number" && value > schema.maximum)
return `${path}:maximum`;
if (
typeof schema.exclusiveMinimum === "number" &&
value <= schema.exclusiveMinimum
)
return `${path}:exclusiveMinimum`;
if (
typeof schema.exclusiveMaximum === "number" &&
value >= schema.exclusiveMaximum
)
return `${path}:exclusiveMaximum`;
if (
typeof schema.multipleOf === "number" &&
(schema.multipleOf <= 0 ||
Math.abs(
value / schema.multipleOf - Math.round(value / schema.multipleOf),
) > 1e-9)
)
return `${path}:multipleOf`;
}
if (Array.isArray(value)) {
if (typeof schema.minItems === "number" && value.length < schema.minItems)
return `${path}:minItems`;
if (typeof schema.maxItems === "number" && value.length > schema.maxItems)
return `${path}:maxItems`;
if (schema.items !== undefined)
for (let index = 0; index < value.length; index += 1) {
const issue = firstIssue(
value[index],
schema.items as JsonSchema,
`${path}[${index}]`,
depth + 1,
);
if (issue) return issue;
}
}
const record = object(value);
if (record) {
const required = Array.isArray(schema.required) ? schema.required : [];
const missing = required.find(
(key) => !Object.hasOwn(record, key as string),
);
if (missing !== undefined) return `${path}:required:${String(missing)}`;
const properties = object(schema.properties) ?? {};
for (const [key, child] of Object.entries(properties))
if (Object.hasOwn(record, key)) {
const issue = firstIssue(
record[key],
child as JsonSchema,
`${path}.${key}`,
depth + 1,
);
if (issue) return issue;
}
if (schema.additionalProperties === false) {
const extra = Object.keys(record).find(
(key) => !Object.hasOwn(properties, key),
);
if (extra !== undefined) return `${path}:additionalProperties:${extra}`;
}
}
return undefined;
}
function parseJson(value: string): unknown {
return safeJsonParse(value, {
maxTextChars: MAX_INPUT_BYTES,
maxDepth: 64,
maxNodes: 100_000,
rejectDangerousKeys: true,
});
}
function parseXml(value: string): XMLDocument | undefined {
if (/<!DOCTYPE/iu.test(value)) return undefined;
const document = new DOMParser().parseFromString(value, "application/xml");
return document.querySelector("parsererror") ? undefined : document;
}
function assertSafeStylesheet(source: string): XMLDocument {
if (new TextEncoder().encode(source).byteLength > 128 * 1024)
throw new RangeError("XSLT is limited to 128 KiB.");
const document = parseXml(source);
if (!document)
throw new SyntaxError("XSLT is not well-formed XML or contains a DOCTYPE.");
const root = document.documentElement;
if (
root.namespaceURI !== "http://www.w3.org/1999/XSL/Transform" ||
!["stylesheet", "transform"].includes(root.localName)
)
throw new TypeError(
"The stylesheet root must be xsl:stylesheet or xsl:transform.",
);
for (const element of Array.from(
document.getElementsByTagNameNS(
"http://www.w3.org/1999/XSL/Transform",
"*",
),
)) {
if (["include", "import"].includes(element.localName))
throw new TypeError("xsl:include and xsl:import are disabled.");
for (const attribute of Array.from(element.attributes)) {
if (attribute.localName === "href")
throw new TypeError("External stylesheet/output hrefs are disabled.");
if (
/\b(?:document|unparsed-text|collection)\s*\(/iu.test(attribute.value)
)
throw new TypeError("External-resource XPath functions are disabled.");
}
}
return document;
}
function xsltOutput(
xml: string,
stylesheet: XMLDocument,
): { threw: boolean; output: string } {
const document = parseXml(xml);
if (!document) return { threw: false, output: "" };
if (typeof XSLTProcessor === "undefined")
throw new TypeError("This browser does not provide the XSLTProcessor API.");
try {
const processor = new XSLTProcessor();
processor.importStylesheet(stylesheet);
const result = processor.transformToDocument(document);
return {
threw: false,
output: new XMLSerializer().serializeToString(result),
};
} catch {
return { threw: true, output: "" };
}
}
export function createPredicate(
recipe: PredicateRecipe,
evaluateRegex: RegexEvaluator,
): (candidate: string, signal: AbortSignal) => Promise<boolean> {
if (recipe.kind === "contains") {
if (!recipe.needle) throw new TypeError("A non-empty marker is required.");
return async (candidate) => candidate.includes(recipe.needle!);
}
if (recipe.kind === "invalid-json")
return async (candidate) => {
try {
parseJson(candidate);
return false;
} catch {
return true;
}
};
if (recipe.kind === "invalid-xml")
return async (candidate) => !parseXml(candidate);
if (recipe.kind === "json-schema-fails") {
if (!recipe.schema)
throw new TypeError("A focused JSON Schema is required.");
const schema = parseJson(recipe.schema);
assertSchema(schema);
let baselineIssue: string | undefined;
return async (candidate) => {
try {
const issue = firstIssue(parseJson(candidate), schema);
if (!issue) return false;
if (recipe.preserveFailureSignature !== false) {
baselineIssue ??= issue;
return issue === baselineIssue;
}
return true;
} catch {
return false;
}
};
}
if (recipe.kind === "regex-matches" || recipe.kind === "regex-slow") {
if (!recipe.pattern)
throw new TypeError("A regular expression is required.");
const flags = recipe.flags ?? "u";
if (/[^dgimsuvy]/u.test(flags) || new Set(flags).size !== flags.length)
throw new TypeError(
"Regular-expression flags are invalid or duplicated.",
);
const threshold = Math.max(1, Math.min(500, recipe.thresholdMs ?? 25));
return async (candidate, signal) => {
const result = await evaluateRegex(
candidate,
recipe.pattern!,
flags,
threshold,
signal,
);
return recipe.kind === "regex-matches"
? !result.timedOut && result.matched
: result.timedOut || result.elapsedMs >= threshold;
};
}
if (!recipe.stylesheet)
throw new TypeError("A local XSLT stylesheet is required.");
const stylesheet = assertSafeStylesheet(recipe.stylesheet);
if (recipe.kind === "xslt-output-contains" && !recipe.needle)
throw new TypeError("An XSLT output marker is required.");
return async (candidate) => {
const result = xsltOutput(candidate, stylesheet);
return recipe.kind === "xslt-throws"
? result.threw
: !result.threw && result.output.includes(recipe.needle!);
};
}
+30
View File
@@ -0,0 +1,30 @@
/// <reference lib="webworker" />
interface Request {
id: number;
candidate: string;
pattern: string;
flags: string;
}
self.onmessage = (event: MessageEvent<Request>) => {
const { id, candidate, pattern, flags } = event.data;
try {
const expression = new RegExp(pattern, flags);
const started = performance.now();
const matched = expression.test(candidate);
self.postMessage({
id,
matched,
elapsedMs: performance.now() - started,
});
} catch (reason) {
self.postMessage({
id,
error:
reason instanceof Error ? reason.message : "Regex evaluation failed.",
});
}
};
export {};
+594
View File
@@ -0,0 +1,594 @@
:root {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #242233;
background: #f3f4f8;
font-synthesis: none;
--surface: #fff;
--surface-muted: #f3f1fa;
--line: #d8d6e5;
--ink-muted: #66637a;
--accent: #5b4ec4;
--accent-dark: #4336a4;
--danger: #a5263d;
--warning: #8a5700;
--success: #187148;
}
:root[data-toolbox-theme="dark"] {
color: #f0eff8;
background: #15141b;
--surface: #211f29;
--surface-muted: #2a2735;
--line: #454153;
--ink-muted: #b9b5c8;
--accent: #a99cff;
--accent-dark: #c0b7ff;
--danger: #ff91a2;
--warning: #ffd078;
--success: #78d7a9;
}
@media (prefers-color-scheme: dark) {
:root:not([data-toolbox-theme="light"]) {
color: #f0eff8;
background: #15141b;
--surface: #211f29;
--surface-muted: #2a2735;
--line: #454153;
--ink-muted: #b9b5c8;
--accent: #a99cff;
--accent-dark: #c0b7ff;
--danger: #ff91a2;
--warning: #ffd078;
--success: #78d7a9;
}
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: inherit;
color: inherit;
}
button,
input,
select,
textarea {
color: inherit;
font: inherit;
}
button,
.button,
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 0.72rem;
background: var(--surface);
}
button,
.button {
min-height: 2.65rem;
padding: 0.58rem 0.9rem;
cursor: pointer;
font-weight: 750;
}
button:hover,
.button:hover {
border-color: var(--accent);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.button:focus-within {
outline: 3px solid color-mix(in srgb, var(--accent) 36%, transparent);
outline-offset: 2px;
}
input,
select,
textarea {
width: 100%;
min-height: 2.55rem;
padding: 0.55rem 0.65rem;
}
textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.84rem;
line-height: 1.5;
tab-size: 2;
}
.workbench {
width: min(100%, 90rem);
margin: 0 auto;
padding: 1.4rem clamp(0.75rem, 2.5vw, 2rem) 3rem;
}
.hero,
.panel-heading,
.source-grid,
.option-grid,
.tabs,
.workspace-grid,
.range-inputs {
display: flex;
}
.hero {
align-items: flex-start;
justify-content: space-between;
gap: 1.2rem;
margin-bottom: 1rem;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.35rem;
font-size: clamp(1.85rem, 4vw, 3rem);
}
h2 {
margin-bottom: 0;
font-size: 1.16rem;
}
.hero > div > p:last-child {
max-width: 52rem;
margin-bottom: 0;
color: var(--ink-muted);
}
.eyebrow {
margin-bottom: 0.28rem;
color: var(--accent-dark);
font-size: 0.74rem;
font-weight: 850;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.privacy-pill,
.count-pill {
flex: none;
border: 1px solid color-mix(in srgb, var(--success) 48%, var(--line));
border-radius: 999px;
padding: 0.48rem 0.72rem;
color: var(--success);
font-size: 0.8rem;
font-weight: 800;
}
.panel {
margin-bottom: 0.85rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--surface);
box-shadow: 0 0.45rem 1.4rem color-mix(in srgb, #151126 7%, transparent);
padding: 1rem;
}
.panel-heading {
align-items: center;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.panel-heading p {
margin: 0.2rem 0 0;
color: var(--ink-muted);
font-size: 0.88rem;
}
.file-button {
position: relative;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.file-button input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.source-grid,
.option-grid {
align-items: end;
flex-wrap: wrap;
gap: 0.65rem;
margin-bottom: 0.72rem;
}
.source-grid label,
.option-grid label,
.panel-heading label {
display: grid;
gap: 0.28rem;
min-width: 10rem;
color: var(--ink-muted);
font-size: 0.8rem;
font-weight: 750;
}
.source-grid label {
flex: 1 1 14rem;
}
.option-grid label {
flex: 1 1 10rem;
}
.primary-button {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
:root[data-toolbox-theme="dark"] .primary-button {
color: #17131f;
}
.tabs {
gap: 0.35rem;
overflow-x: auto;
margin: 0 0 0.85rem;
padding: 0.1rem 0;
}
.tabs button {
white-space: nowrap;
}
.tabs button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--surface-muted);
color: var(--accent-dark);
}
.workspace-grid {
align-items: stretch;
gap: 0.85rem;
}
.workspace-grid > * {
min-width: 0;
flex: 1 1 0;
}
.table-scroll {
max-width: 100%;
overflow: auto;
border: 1px solid var(--line);
border-radius: 0.8rem;
}
.table-scroll:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 36%, transparent);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 0.55rem;
text-align: left;
vertical-align: top;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--surface-muted);
white-space: nowrap;
}
tbody tr:last-child > * {
border-bottom: 0;
}
.field-table {
min-width: 70rem;
}
.field-table input,
.field-table select {
min-width: 8rem;
}
.flag-cell label {
display: flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.flag-cell label + label {
margin-top: 0.4rem;
}
.flag-cell input {
width: auto;
min-height: 0;
}
.range-inputs {
gap: 0.35rem;
}
.range-inputs input {
min-width: 6rem;
}
.icon-button {
min-height: 2.3rem;
padding: 0.4rem 0.62rem;
font-size: 0.78rem;
}
.danger {
color: var(--danger);
}
.records {
max-height: 42rem;
}
.records td {
max-width: 18rem;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.export-panel textarea {
min-height: 30rem;
}
.violation-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.55rem;
margin: 0;
padding: 0;
list-style: none;
}
.violation-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
border: 1px solid color-mix(in srgb, var(--danger) 38%, var(--line));
border-radius: 0.7rem;
padding: 0.58rem;
}
.violation-list strong {
color: var(--danger);
font-size: 0.78rem;
}
.warning,
.error,
.success,
.empty {
margin: 0.65rem 0 0;
font-size: 0.88rem;
}
.warning {
color: var(--warning);
}
.error {
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line));
border-radius: 0.72rem;
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
padding: 0.66rem;
color: var(--danger);
}
.success {
color: var(--success);
}
.empty {
color: var(--ink-muted);
}
.top-gap {
margin-top: 0.75rem;
}
.preset-row,
.action-row {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.preset-row {
margin-bottom: 0.85rem;
}
.work-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.82fr);
align-items: start;
gap: 0.85rem;
}
.work-grid > * {
min-width: 0;
}
.input-panel > label,
.predicate-panel > label,
.compact-grid label {
display: grid;
gap: 0.3rem;
margin-bottom: 0.7rem;
color: var(--ink-muted);
font-size: 0.8rem;
font-weight: 750;
}
.input-panel > label:first-of-type {
max-width: 15rem;
}
.compact-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(6rem, 0.32fr);
gap: 0.55rem;
}
.compact-grid label:first-child:last-child,
.compact-grid label:nth-child(3) {
grid-column: 1 / -1;
}
.check-row {
display: flex !important;
grid-template-columns: auto 1fr;
align-items: center;
flex-direction: row;
}
.check-row input {
width: auto;
min-height: 0;
}
.limits {
margin-top: 0.85rem;
}
.running {
margin: 0.75rem 0 0;
color: var(--accent-dark);
font-weight: 750;
}
.result-panel textarea {
min-height: 18rem;
}
.step-list {
max-height: 18rem;
overflow: auto;
padding-left: 1.5rem;
color: var(--ink-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.8rem;
}
details {
margin-top: 0.7rem;
}
summary {
cursor: pointer;
font-weight: 750;
}
.loading,
.fatal {
width: min(100%, 90rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(38rem, calc(100vw - 2rem));
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--surface);
color: inherit;
padding: 1rem;
}
.help-dialog::backdrop {
background: rgb(16 14 25 / 58%);
}
.dialog-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
@media (max-width: 860px) {
.hero,
.workspace-grid {
flex-direction: column;
}
.privacy-pill {
align-self: flex-start;
}
.workspace-grid > * {
width: 100%;
}
.panel-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.work-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.minimize-tools",
"name": "Minimize Tools",
"version": "0.1.0",
"description": "Reduce failing inputs while preserving the failure locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "data", "testing"],
"tags": [
"minimize",
"delta-debugging",
"reproducer",
"json-schema",
"xslt",
"regex"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs and predicates stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/minimize-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/minimize-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />