Skip to main content
This example demonstrates how to build a pipeline that transforms minified JavaScript into readable code, mixing LLM-powered analysis with deterministic transformations. It showcases Thinkwell’s core pattern: use deterministic tools where they excel, and delegate semantic understanding to the AI agent.

What the Unminifier Does

The unminifier takes minified JavaScript (like underscore-umd-min.js) and produces readable code through five steps:
  1. Pretty-print with Prettier (deterministic)
  2. Convert UMD to ESM with ast-grep pattern matching (deterministic)
  3. Extract functions - identify all top-level functions with minified names (LLM)
  4. Analyze functions - suggest descriptive names for each function in parallel batches (LLM)
  5. Apply renames with Babel’s scope-aware renaming (deterministic)
This pipeline illustrates a key insight: some transformations are purely mechanical (formatting, module conversion, renaming), while others require semantic understanding (identifying and naming functions). Thinkwell lets you mix both approaches naturally.

The Schema Definitions

The LLM steps (3 and 4) use structured outputs defined with the @JSONSchema annotation:
/**
 * Information about a function found in the code.
 * @JSONSchema
 */
export interface FunctionInfo {
  /** Current function name (may be minified) */
  name: string;
  /** Function signature including parameters */
  signature: string;
  /** Approximate line number where the function is defined */
  lineNumber: number;
}

/**
 * List of functions extracted from the code.
 * @JSONSchema
 */
export interface FunctionList {
  /** List of all top-level functions in the code */
  functions: FunctionInfo[];
}

/**
 * Analysis result for a single function.
 * @JSONSchema
 */
export interface FunctionAnalysis {
  /** The original minified function name */
  originalName: string;
  /** Suggested descriptive name (camelCase, no underscores unless conventional) */
  suggestedName: string;
  /** Brief description of what the function does */
  purpose: string;
  /** Confidence level in the suggested name */
  confidence: "high" | "medium" | "low";
}

/**
 * Batch of function analyses.
 * @JSONSchema
 */
export interface FunctionAnalysisBatch {
  /** Array of function analyses */
  analyses: FunctionAnalysis[];
}
Notice how each schema serves the LLM steps:
  • FunctionList uses a nested structure to return multiple items
  • FunctionAnalysis includes a confidence field with literal union types for quality signals
  • FunctionAnalysisBatch wraps multiple analyses for efficient batched processing

Step 1: Pretty-Print with Prettier

The first step is purely mechanical - no LLM required. Use Prettier to make the minified code readable:
import * as prettier from "prettier";

export async function formatCode(code: string): Promise<string> {
  return prettier.format(code, {
    parser: "babel",
    printWidth: 100,
    tabWidth: 2,
    semi: true,
    singleQuote: false,
  });
}
This demonstrates an important pattern: use deterministic tools where they excel. Prettier handles formatting perfectly — there’s no need to involve an LLM.

Step 2: Convert UMD to ESM

UMD boilerplate follows a predictable pattern, so there’s no need for an LLM here. We use ast-grep to match the UMD wrapper and rewrite it in a single pass:
import * as os from "os";
import * as path from "path";
import { execFile } from "child_process";

const tmpFile = path.join(os.tmpdir(), `unminify-${Date.now()}.js`);
await fs.writeFile(tmpFile, prettyCode);
await new Promise<void>((resolve, reject) => {
  execFile("ast-grep", [
    "run",
    "--pattern", "!($IIFE)(this, function () { $$$BODY return $RET; })",
    "--rewrite", "$$$BODY\nexport default $RET;",
    "--lang", "js",
    "--update-all",
    tmpFile,
  ], (err) => err ? reject(err) : resolve());
});

const esmCode = await formatCode(await fs.readFile(tmpFile, "utf-8"));
await fs.unlink(tmpFile).catch(() => {});
The ast-grep pattern !($IIFE)(this, function () { $$$BODY return $RET; }) matches the UMD IIFE wrapper. The $$$BODY metavariable captures all statements inside, and $RET captures the return value. The rewrite replaces the entire wrapper with just the body plus export default $RET;. Once again, this demonstrates the benefits of using deterministic tools where they excel: ast-grep is convenient and effective for matching simple syntactic patterns. Not only that, it’s highly efficient and cheaper; transforming a large source file like Underscore with an LLM chews through quite a few tokens.

Step 3: Extract Function List

Next, ask the agent to identify all functions that need renaming:
const functionList = await agent
  .think(FunctionList.Schema)
  .text(`
    Extract a list of all top-level function declarations and function expressions
    assigned to variables in this code. Include the function name, its signature
    (parameters), and approximate line number. Focus on functions with short
    (1-2 character) names that appear to be minified.

  `)
  .code(esmCode, "javascript")
  .run();

console.log(`Found ${functionList.functions.length} functions to analyze`);
The structured FunctionList output gives you an array you can iterate over in TypeScript - bridging the gap between natural language analysis and programmatic control flow.

Step 4: Parallel Batch Analysis

Analyzing functions one at a time would be slow. Instead, batch them and process in parallel with a concurrency limit:
import pLimit from "p-limit";
import _ from "lodash";

const renames: Map<string, string> = new Map();
const limit = pLimit(5);  // Max 5 concurrent requests
const batches = _.chunk(functionList.functions, 30);  // 30 functions per batch

const results = await Promise.all(
  batches.map((batch) =>
    limit(async () => {
      const functionListText = batch
        .map((f) => `  - "${f.name}" with signature ${f.signature}`)
        .join("\n");

      return agent
        .think(FunctionAnalysisBatch.Schema)
        .text(`
          Analyze each of the following minified functions and suggest better,
          more descriptive names.

          IMPORTANT: For each function, the 'originalName' field in your response
          must be the EXACT minified name shown in quotes below.

          Functions to analyze:

          ${functionListText}

          Here is the full code for context:

        `)
        .code(esmCode, "javascript")
        .run();
    })
  )
);

// Collect all renames
for (const batch of results) {
  for (const analysis of batch.analyses) {
    if (analysis.suggestedName !== analysis.originalName) {
      renames.set(analysis.originalName, analysis.suggestedName);
      console.log(`  ${analysis.originalName} -> ${analysis.suggestedName}`);
    }
  }
}
Key patterns here:
  • p-limit avoids the risk of rate-limiting or API bans
  • _.chunk groups functions into manageable batches
  • FunctionAnalysisBatch returns multiple analyses per request, reducing round trips
  • Sending full source code as context helps the LLM understand function interdependencies

Step 5: Apply Renames

Renaming is another task that doesn’t need an LLM — Babel’s scope-aware renaming handles it correctly and instantly:
import { parse as babelParse } from "@babel/parser";
import { generate } from "@babel/generator";
import { default as _traverse } from "@babel/traverse";

const traverse: typeof _traverse = (_traverse as any).default ?? _traverse;

const ast = babelParse(esmCode, { sourceType: "module", plugins: [] });
let renameCount = 0;

traverse(ast, {
  Program(path: any) {
    for (const [oldName, newName] of renames) {
      if (path.scope.getBinding(oldName)) {
        path.scope.rename(oldName, newName);
        renameCount++;
      }
    }
  },
});

const renamedCode = generate(ast, { retainLines: false, comments: true }).code;
const finalCode = await formatCode(renamedCode);
console.log(`Applied ${renameCount} renames`);
Babel’s scope.rename() is scope-aware, so it correctly renames function definitions and all their usages without touching unrelated identifiers that happen to share the same name. This is more reliable than asking an LLM to regenerate the entire file — and runs in milliseconds.

The Complete Pipeline

Here’s how it all fits together — notice how LLM and deterministic steps interleave naturally:
import { open } from "thinkwell";
import * as fs from "fs/promises";
import * as os from "os";
import * as path from "path";
import { execFile } from "child_process";
import * as prettier from "prettier";
import pLimit from "p-limit";
import _ from "lodash";
import { parse as babelParse } from "@babel/parser";
import { generate } from "@babel/generator";
import { default as _traverse } from "@babel/traverse";

const traverse: typeof _traverse = (_traverse as any).default ?? _traverse;

async function main() {
  const agent = await open('claude');

  try {
    const minifiedCode = await fs.readFile("underscore-umd-min.js", "utf-8");

    // Step 1: Pretty-print (deterministic — Prettier)
    const prettyCode = await formatCode(minifiedCode);

    // Step 2: Convert UMD to ESM (deterministic — ast-grep)
    const tmpFile = path.join(os.tmpdir(), `unminify-${Date.now()}.js`);
    await fs.writeFile(tmpFile, prettyCode);
    await new Promise<void>((resolve, reject) => {
      execFile("ast-grep", [
        "run",
        "--pattern", "!($IIFE)(this, function () { $$$BODY return $RET; })",
        "--rewrite", "$$$BODY\nexport default $RET;",
        "--lang", "js", "--update-all", tmpFile,
      ], (err) => err ? reject(err) : resolve());
    });
    const esmCode = await formatCode(await fs.readFile(tmpFile, "utf-8"));

    // Step 3: Extract function list (LLM)
    const functionList = await agent
      .think(FunctionList.Schema)
      .text(`Extract all top-level functions...`)
      .code(esmCode, "javascript")
      .run();

    // Step 4: Analyze functions in parallel batches (LLM)
    const renames = new Map<string, string>();
    // ... batch processing with p-limit ...

    // Step 5: Apply renames (deterministic — Babel)
    const ast = babelParse(esmCode, { sourceType: "module", plugins: [] });
    traverse(ast, {
      Program(path: any) {
        for (const [oldName, newName] of renames) {
          if (path.scope.getBinding(oldName)) {
            path.scope.rename(oldName, newName);
          }
        }
      },
    });
    const finalCode = await formatCode(generate(ast).code);

    await fs.writeFile("underscore.js", finalCode, "utf-8");
  } finally {
    agent.close();
  }
}

main();

Running the Example

thinkwell examples/src/unminify.ts
Sample output:
=== Unminify Demo ===

Reading minified code...
Input: 18432 characters

Step 1: Pretty-printing with Prettier...
Pretty-printed: 892 lines

Step 2: Converting UMD wrapper to ESM...
ESM module: 879 lines

Step 3: Extracting function list...
Found 47 functions to analyze

Step 4: Analyzing functions in batches...
  Processing 2 batches...
  j -> isArrayLike
  w -> createCallback
  A -> optimizeCb
  ...

Step 5: Applying renames with Babel...
Applied 47 renames

=== Done! ===

Key Takeaways

  1. Use deterministic tools where they excel - Prettier for formatting, ast-grep for structural rewrites, Babel for scope-aware renaming
  2. Reserve the LLM for semantic understanding - only steps 3 and 4 (function extraction and naming) actually need AI
  3. Parallel processing with p-limit keeps LLM-powered steps efficient without exhausting account limits
  4. Batch schemas like FunctionAnalysisBatch reduce round trips for repeated LLM operations
  5. The .code() method clearly delineates code blocks in prompts
  6. Deterministic steps are faster and more reliable - the ast-grep rewrite runs in ~130ms vs ~200s for LLM regeneration