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
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add stable retry voting, multi-file cases, adapter-defined predicates and
exported reproducibility evidence for flaky or structured failures.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded structure-aware JSON/XML and textual delta debugging - Add bounded structure-aware JSON/XML and textual delta debugging
+9 -1
View File
@@ -4,7 +4,7 @@ Reduce a failing input while repeatedly proving that the selected failure still
occurs. Minimize Tools is a local-first delta-debugging module in the add·ideas occurs. Minimize Tools is a local-first delta-debugging module in the add·ideas
Toolbox. Toolbox.
## v0.1.0 capabilities ## v0.2.0 capabilities
- Minimize plain text by lines, lexical tokens, and Unicode code points - Minimize plain text by lines, lexical tokens, and Unicode code points
- Apply structure-aware reductions before textual delta debugging for bounded - Apply structure-aware reductions before textual delta debugging for bounded
@@ -18,6 +18,11 @@ Toolbox.
timeout; cancel any run and retain the last complete result timeout; cancel any run and retain the last complete result
- Enforce explicit predicate-test and wall-time budgets and show every accepted - Enforce explicit predicate-test and wall-time budgets and show every accepted
size reduction size reduction
- Sample predicates with single, majority or strict stability profiles and
report observed flaky candidates rather than silently trusting one run
- Minimize bounded multi-file bundles first by file membership and then by
structure-aware file content, using deterministic JSON-map, delimited or
primary-file adapter profiles
- Export the minimized case and a deterministic report containing the predicate, - Export the minimized case and a deterministic report containing the predicate,
engine version, limits, and reduction trace engine version, limits, and reduction trace
@@ -34,6 +39,9 @@ Minimization finds a locally minimal case under the attempted transformations
and budget, not a mathematically globally shortest input. Regex timing depends on and budget, not a mathematically globally shortest input. Regex timing depends on
the browser and device. Inputs are capped at 2 MiB, runs at 2,000 tests and 30 the browser and device. Inputs are capped at 2 MiB, runs at 2,000 tests and 30
seconds, regex worker execution at one second, and stylesheets at 128 KiB. seconds, regex worker execution at one second, and stylesheets at 128 KiB.
Bundles are limited to 100 files, 4 MiB total and 2 MiB per file. Adapter
profiles serialize bundle candidates for an existing predicate; they do not
emulate a filesystem or execute a target program.
## Development ## Development
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Minimize Tools 0.1.0 is available at: The corresponding source for Minimize Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/minimize-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/minimize-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # Third-party notices
Minimize Tools 0.1.0 directly depends on these runtime packages: Minimize Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+19 -19
View File
@@ -1,22 +1,22 @@
{ {
"name": "minimize-tools", "name": "minimize-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "minimize-tools", "name": "minimize-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,25 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,17 +68,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "minimize-tools", "name": "minimize-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Reduce failing inputs while preserving a bounded local failure predicate.", "description": "Reduce failing inputs while preserving a bounded local failure predicate.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add stable retry voting, multi-file cases, adapter-defined predicates and
exported reproducibility evidence for flaky or structured failures.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded structure-aware JSON/XML and textual delta debugging - Add bounded structure-aware JSON/XML and textual delta debugging
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
============================================================================== ==============================================================================
@add-ideas/toolbox-helpers@0.1.0 @add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
============================================================================== ==============================================================================
@add-ideas/toolbox-shell-react@0.2.3 @add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+9 -1
View File
@@ -4,7 +4,7 @@ Reduce a failing input while repeatedly proving that the selected failure still
occurs. Minimize Tools is a local-first delta-debugging module in the add·ideas occurs. Minimize Tools is a local-first delta-debugging module in the add·ideas
Toolbox. Toolbox.
## v0.1.0 capabilities ## v0.2.0 capabilities
- Minimize plain text by lines, lexical tokens, and Unicode code points - Minimize plain text by lines, lexical tokens, and Unicode code points
- Apply structure-aware reductions before textual delta debugging for bounded - Apply structure-aware reductions before textual delta debugging for bounded
@@ -18,6 +18,11 @@ Toolbox.
timeout; cancel any run and retain the last complete result timeout; cancel any run and retain the last complete result
- Enforce explicit predicate-test and wall-time budgets and show every accepted - Enforce explicit predicate-test and wall-time budgets and show every accepted
size reduction size reduction
- Sample predicates with single, majority or strict stability profiles and
report observed flaky candidates rather than silently trusting one run
- Minimize bounded multi-file bundles first by file membership and then by
structure-aware file content, using deterministic JSON-map, delimited or
primary-file adapter profiles
- Export the minimized case and a deterministic report containing the predicate, - Export the minimized case and a deterministic report containing the predicate,
engine version, limits, and reduction trace engine version, limits, and reduction trace
@@ -34,6 +39,9 @@ Minimization finds a locally minimal case under the attempted transformations
and budget, not a mathematically globally shortest input. Regex timing depends on and budget, not a mathematically globally shortest input. Regex timing depends on
the browser and device. Inputs are capped at 2 MiB, runs at 2,000 tests and 30 the browser and device. Inputs are capped at 2 MiB, runs at 2,000 tests and 30
seconds, regex worker execution at one second, and stylesheets at 128 KiB. seconds, regex worker execution at one second, and stylesheets at 128 KiB.
Bundles are limited to 100 files, 4 MiB total and 2 MiB per file. Adapter
profiles serialize bundle candidates for an existing predicate; they do not
emulate a filesystem or execute a target program.
## Development ## Development
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Minimize Tools 0.1.0 is available at: The corresponding source for Minimize Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/minimize-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/minimize-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # Third-party notices
Minimize Tools 0.1.0 directly depends on these runtime packages: Minimize Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "minimize-tools-shell-"; const CACHE_PREFIX = "minimize-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0"; const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+19 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.minimize-tools", "id": "de.add-ideas.minimize-tools",
"name": "Minimize Tools", "name": "Minimize Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Reduce failing inputs while preserving the failure locally.", "description": "Reduce failing inputs while preserving the failure locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -23,11 +23,28 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{ "mediaType": "text/plain", "extensions": [".txt"] },
{ "mediaType": "application/json", "extensions": [".json"] },
{
"mediaType": "application/xml",
"extensions": [".xml", ".xsl", ".xslt"]
}
],
"produces": [
{ "mediaType": "text/plain", "extensions": [".txt"] },
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/xml", "extensions": [".xml"] },
{ "mediaType": "application/zip", "extensions": [".zip"] }
]
},
"capabilities": { "required": [], "optional": ["workers"] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+148
View File
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState, type ChangeEvent } from "react";
import { import {
stableStringify, stableStringify,
triggerBlobDownload, triggerBlobDownload,
triggerBlobDownloads,
} from "@add-ideas/toolbox-helpers"; } from "@add-ideas/toolbox-helpers";
import { import {
byteLength, byteLength,
@@ -9,7 +10,15 @@ import {
type InputStructure, type InputStructure,
type MinimizeProgress, type MinimizeProgress,
type MinimizeResult, type MinimizeResult,
type PredicateStability,
} from "../minimize/engine"; } from "../minimize/engine";
import {
bundlePredicateAdapter,
minimizeBundle,
type BundleAdapterProfile,
type BundleFile,
type BundleMinimizeResult,
} from "../minimize/bundle";
import { import {
createPredicate, createPredicate,
type PredicateKind, type PredicateKind,
@@ -159,6 +168,13 @@ export function Workbench() {
const [preserveSignature, setPreserveSignature] = useState(true); const [preserveSignature, setPreserveSignature] = useState(true);
const [maxTests, setMaxTests] = useState(500); const [maxTests, setMaxTests] = useState(500);
const [maxSeconds, setMaxSeconds] = useState(10); const [maxSeconds, setMaxSeconds] = useState(10);
const [stabilityProfile, setStabilityProfile] = useState<
"single" | "majority" | "strict"
>("majority");
const [bundleFiles, setBundleFiles] = useState<BundleFile[]>([]);
const [bundleAdapter, setBundleAdapter] =
useState<BundleAdapterProfile["id"]>("concatenated");
const [bundleResult, setBundleResult] = useState<BundleMinimizeResult>();
const [result, setResult] = useState<MinimizeResult>(); const [result, setResult] = useState<MinimizeResult>();
const [resultRecipe, setResultRecipe] = useState<PredicateRecipe>(); const [resultRecipe, setResultRecipe] = useState<PredicateRecipe>();
const [resultLimits, setResultLimits] = useState<{ const [resultLimits, setResultLimits] = useState<{
@@ -183,6 +199,12 @@ export function Workbench() {
stylesheet, stylesheet,
preserveFailureSignature: preserveSignature, preserveFailureSignature: preserveSignature,
}; };
const stability: PredicateStability =
stabilityProfile === "single"
? { samples: 1, requiredPasses: 1 }
: stabilityProfile === "strict"
? { samples: 3, requiredPasses: 3 }
: { samples: 3, requiredPasses: 2 };
const report = useMemo( const report = useMemo(
() => () =>
@@ -199,6 +221,8 @@ export function Workbench() {
accepted: result.accepted, accepted: result.accepted,
exhausted: result.exhausted, exhausted: result.exhausted,
steps: result.steps, steps: result.steps,
stability: result.stability,
unstableCandidates: result.unstableCandidates,
}, },
2, 2,
{ maxTextChars: 2 * 1024 * 1024, maxDepth: 16, maxNodes: 20_000 }, { maxTextChars: 2 * 1024 * 1024, maxDepth: 16, maxNodes: 20_000 },
@@ -219,6 +243,19 @@ export function Workbench() {
setError(undefined); setError(undefined);
try { try {
const predicate = createPredicate(recipe, runner.evaluate); const predicate = createPredicate(recipe, runner.evaluate);
if (bundleFiles.length) {
const next = await minimizeBundle(
bundleFiles,
{ maxTests, maxSeconds, stability },
bundlePredicateAdapter(predicate, bundleAdapter),
nextController.signal,
);
setBundleResult(next);
setResult(undefined);
setResultRecipe(structuredClone(recipe));
setResultLimits({ maxTests, maxSeconds });
return;
}
const next = await minimizeInput( const next = await minimizeInput(
source, source,
{ {
@@ -229,12 +266,14 @@ export function Workbench() {
? Math.min(maxTests, 200) ? Math.min(maxTests, 200)
: maxTests, : maxTests,
maxSeconds, maxSeconds,
stability,
}, },
predicate, predicate,
nextController.signal, nextController.signal,
setProgress, setProgress,
); );
setResult(next); setResult(next);
setBundleResult(undefined);
setResultRecipe(structuredClone(recipe)); setResultRecipe(structuredClone(recipe));
setResultLimits({ setResultLimits({
maxTests: maxTests:
@@ -270,6 +309,7 @@ export function Workbench() {
return; return;
} }
setSource(await file.text()); setSource(await file.text());
setBundleFiles([]);
setSourceName(file.name); setSourceName(file.name);
setStructure( setStructure(
file.name.toLowerCase().endsWith(".json") file.name.toLowerCase().endsWith(".json")
@@ -281,6 +321,27 @@ export function Workbench() {
setError(undefined); setError(undefined);
}; };
const openBundle = async (event: ChangeEvent<HTMLInputElement>) => {
const files = [...(event.target.files ?? [])];
event.target.value = "";
try {
if (files.length > 100)
throw new RangeError("Bundles are limited to 100 files.");
const loaded = await Promise.all(
files.map(async (file) => ({
path: file.webkitRelativePath || file.name,
content: await file.text(),
})),
);
setBundleFiles(loaded);
setError(undefined);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Bundle loading failed.",
);
}
};
const applyPreset = (name: "json" | "xml" | "regex") => { const applyPreset = (name: "json" | "xml" | "regex") => {
if (name === "json") { if (name === "json") {
setSource(JSON_SAMPLE); setSource(JSON_SAMPLE);
@@ -349,6 +410,14 @@ export function Workbench() {
onChange={(event) => void openFile(event)} onChange={(event) => void openFile(event)}
/> />
</label> </label>
<label className="button file-button">
Open file bundle
<input
type="file"
multiple
onChange={(event) => void openBundle(event)}
/>
</label>
</div> </div>
<label> <label>
Structure Structure
@@ -370,6 +439,15 @@ export function Workbench() {
rows={22} rows={22}
spellCheck={false} spellCheck={false}
/> />
{bundleFiles.length ? (
<div className="bundle-summary">
<strong>{bundleFiles.length} bundle files active</strong>
<p>{bundleFiles.map((file) => file.path).join(" · ")}</p>
<button type="button" onClick={() => setBundleFiles([])}>
Return to single input
</button>
</div>
) : null}
</article> </article>
<article className="panel predicate-panel"> <article className="panel predicate-panel">
@@ -483,6 +561,37 @@ export function Workbench() {
</label> </label>
) : null} ) : null}
<div className="compact-grid limits"> <div className="compact-grid limits">
<label>
Predicate stability
<select
value={stabilityProfile}
onChange={(event) =>
setStabilityProfile(
event.target.value as typeof stabilityProfile,
)
}
>
<option value="single">Single observation</option>
<option value="majority">Majority (2 of 3)</option>
<option value="strict">Strict (3 of 3)</option>
</select>
</label>
<label>
Bundle predicate adapter
<select
value={bundleAdapter}
disabled={!bundleFiles.length}
onChange={(event) =>
setBundleAdapter(
event.target.value as BundleAdapterProfile["id"],
)
}
>
<option value="concatenated">Delimited concatenation</option>
<option value="json-map">JSON path map</option>
<option value="primary-file">Primary file only</option>
</select>
</label>
<label> <label>
Maximum predicate tests Maximum predicate tests
<input <input
@@ -591,6 +700,45 @@ export function Workbench() {
</details> </details>
) : null} ) : null}
</section> </section>
{bundleResult ? (
<section className="panel result-panel" aria-live="polite">
<div className="panel-heading">
<div>
<h2>Minimized bundle</h2>
<p>
{bundleResult.original.length} {bundleResult.minimized.length}{" "}
files · {bundleResult.tests} predicate observations ·{" "}
{bundleResult.unstableCandidates} flaky candidates observed
</p>
</div>
<button
type="button"
onClick={() =>
triggerBlobDownloads(
bundleResult.minimized.map((file) => ({
blob: new Blob([file.content], {
type: "text/plain;charset=utf-8",
}),
filename: file.path.replaceAll("/", "__"),
})),
{ order: "filename", maximumFiles: 100 },
)
}
>
Download retained files
</button>
</div>
<ul className="step-list">
{bundleResult.minimized.map((file) => (
<li key={file.path}>
<code>{file.path}</code> ·{" "}
{byteLength(file.content).toLocaleString()} bytes
</li>
))}
</ul>
</section>
) : null}
</main> </main>
); );
} }
+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";
}
+40 -2
View File
@@ -11,6 +11,14 @@ export interface MinimizeOptions {
structure: InputStructure; structure: InputStructure;
maxTests: number; maxTests: number;
maxSeconds: number; maxSeconds: number;
stability?: PredicateStability;
}
export interface PredicateStability {
/** Independent observations per candidate (19). */
samples: number;
/** Required reproductions within those observations (1samples). */
requiredPasses: number;
} }
export interface ReductionStep { export interface ReductionStep {
@@ -35,6 +43,8 @@ export interface MinimizeResult {
accepted: number; accepted: number;
exhausted: boolean; exhausted: boolean;
steps: ReductionStep[]; steps: ReductionStep[];
stability: PredicateStability;
unstableCandidates: number;
} }
export type FailurePredicate = ( export type FailurePredicate = (
@@ -76,6 +86,18 @@ function assertOptions(options: MinimizeOptions): void {
throw new RangeError( throw new RangeError(
`Time budget must be above 0 and at most ${MAX_SECONDS} seconds.`, `Time budget must be above 0 and at most ${MAX_SECONDS} 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(
"Predicate stability needs 19 samples and 1samples required passes.",
);
} }
function splitLines(value: string): string[] { function splitLines(value: string): string[] {
@@ -256,10 +278,12 @@ export async function minimizeInput(
let tests = 0; let tests = 0;
let accepted = 0; let accepted = 0;
let exhausted = false; let exhausted = false;
let unstableCandidates = 0;
let current = input; let current = input;
let stage: ReductionStep["stage"] = "normalize"; let stage: ReductionStep["stage"] = "normalize";
const steps: ReductionStep[] = []; const steps: ReductionStep[] = [];
const cache = new Map<string, boolean>(); const cache = new Map<string, boolean>();
const stability = options.stability ?? { samples: 1, requiredPasses: 1 };
const preserveValidSyntax = const preserveValidSyntax =
options.structure === "json" options.structure === "json"
? (() => { ? (() => {
@@ -282,7 +306,6 @@ export async function minimizeInput(
} }
const cached = cache.get(candidate); const cached = cache.get(candidate);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
tests += 1;
if ( if (
preserveValidSyntax && preserveValidSyntax &&
((options.structure === "json" && ((options.structure === "json" &&
@@ -299,7 +322,20 @@ export async function minimizeInput(
cache.set(candidate, false); cache.set(candidate, false);
return false; return false;
} }
const result = await predicate(candidate, signal); let passes = 0;
let observations = 0;
while (observations < stability.samples) {
if (tests >= options.maxTests || Date.now() >= deadline) {
exhausted = true;
break;
}
tests += 1;
if (await predicate(candidate, signal)) passes += 1;
observations += 1;
}
const result =
observations === stability.samples && passes >= stability.requiredPasses;
if (passes > 0 && passes < observations) unstableCandidates += 1;
cache.set(candidate, result); cache.set(candidate, result);
if (tests % 20 === 0) if (tests % 20 === 0)
onProgress?.({ candidate: current, tests, accepted, stage }); onProgress?.({ candidate: current, tests, accepted, stage });
@@ -407,5 +443,7 @@ export async function minimizeInput(
accepted, accepted,
exhausted, exhausted,
steps, steps,
stability,
unstableCandidates,
}; };
} }
+40 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.minimize-tools", "id": "de.add-ideas.minimize-tools",
"name": "Minimize Tools", "name": "Minimize Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Reduce failing inputs while preserving the failure locally.", "description": "Reduce failing inputs while preserving the failure locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -23,11 +23,49 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "text/plain",
"extensions": [".txt"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/xml",
"extensions": [".xml", ".xsl", ".xslt"]
}
],
"produces": [
{
"mediaType": "text/plain",
"extensions": [".txt"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
},
{
"mediaType": "application/zip",
"extensions": [".zip"]
}
]
},
"capabilities": {
"required": [],
"optional": ["workers"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+20 -1
View File
@@ -81,6 +81,25 @@ test("downloads the minimized case and exact predicate report", async ({
); );
}); });
test("minimizes a deterministic multi-file bundle with stability sampling", async ({
page,
}) => {
await page.goto("/deep/nested/minimize/");
await page.getByLabel("Failure predicate").selectOption("contains");
const bundleInput = page.locator('input[type="file"][multiple]');
await bundleInput.setInputFiles([
"tests/fixtures/noise.txt",
"tests/fixtures/case.txt",
]);
await expect(page.getByText("2 bundle files active")).toBeVisible();
await page.getByRole("button", { name: "Minimize reproducer" }).click();
await expect(
page.getByRole("heading", { name: "Minimized bundle" }),
).toBeVisible();
await expect(page.getByText(/2 1 files/u)).toBeVisible();
await expect(page.getByText(/flaky candidates observed/u)).toBeVisible();
});
test("integrates help, themes, identity, headers and offline reload", async ({ test("integrates help, themes, identity, headers and offline reload", async ({
page, page,
request, request,
@@ -111,7 +130,7 @@ test("integrates help, themes, identity, headers and offline reload", async ({
const manifest = await request.get("/deep/nested/minimize/toolbox-app.json"); const manifest = await request.get("/deep/nested/minimize/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.minimize-tools", id: "de.add-ideas.minimize-tools",
version: "0.1.0", version: "0.2.0",
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
await context.setOffline(true); await context.setOffline(true);
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/minimize/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1
View File
@@ -0,0 +1 @@
prefix BOOM suffix
+1
View File
@@ -0,0 +1 @@
discard this entire file
+47
View File
@@ -4,6 +4,10 @@ import {
createPredicate, createPredicate,
type RegexEvaluator, type RegexEvaluator,
} from "../../src/minimize/predicates"; } from "../../src/minimize/predicates";
import {
bundlePredicateAdapter,
minimizeBundle,
} from "../../src/minimize/bundle";
const unusedRegex: RegexEvaluator = async () => ({ const unusedRegex: RegexEvaluator = async () => ({
matched: false, matched: false,
@@ -125,4 +129,47 @@ describe("minimization engine", () => {
), ),
).toThrow(/include/u); ).toThrow(/include/u);
}); });
it("samples a flaky predicate according to an explicit stability policy", async () => {
const observations = new Map<string, number>();
const result = await minimizeInput(
"noise BOOM noise",
{
structure: "text",
maxTests: 300,
maxSeconds: 5,
stability: { samples: 3, requiredPasses: 2 },
},
async (candidate) => {
const count = (observations.get(candidate) ?? 0) + 1;
observations.set(candidate, count);
return candidate.includes("BOOM") && count % 3 !== 0;
},
new AbortController().signal,
);
expect(result.minimized).toContain("BOOM");
expect(result.stability).toEqual({ samples: 3, requiredPasses: 2 });
expect(result.unstableCandidates).toBeGreaterThan(0);
});
it("removes files and then minimizes retained bundle content", async () => {
const predicate = bundlePredicateAdapter(
createPredicate({ kind: "contains", needle: "BOOM" }, unusedRegex),
"concatenated",
);
const result = await minimizeBundle(
[
{ path: "noise.txt", content: "discard all of this" },
{ path: "case/input.txt", content: "prefix BOOM suffix" },
],
{ maxTests: 400, maxSeconds: 5 },
predicate,
new AbortController().signal,
);
expect(result.minimized).toHaveLength(1);
expect(result.minimized[0]).toMatchObject({ path: "case/input.txt" });
expect(result.minimized[0]?.content).toContain("BOOM");
expect(result.steps.some((step) => step.stage === "files")).toBe(true);
expect(result.steps.some((step) => step.stage === "content")).toBe(true);
});
}); });