@hypercrab/roll-in
v0.3.1
Published
TypeScript dependency inlining, as a reading aid.
Downloads
554
Maintainers
Readme
roll-in
TypeScript dependency inlining, as a reading aid.
A function and everything it transitively calls, rendered as one self-contained piece of code — so behavior can be read in one place instead of chased across files.
npx @hypercrab/roll-in src/orders.ts processOrderIt reads your project's own tsconfig.json — paths, baseUrl, module
resolution and all — so there is nothing to configure and nothing to prepare.
Example
// src/main.ts
import { classify, type Score } from "./classify.js";
export function describeScore(score: Score): string {
return `${score.label} is ${classify(score)}`;
}roll-in src/main.ts describeScore/*
* roll-in — `describeScore`
*
* Source src/main.ts:3-5
* Signature describeScore(score: Score): string
* Depth 2 of 3 allowed
*
* Inlined from this project
* classify ← classify src/classify.ts:8-14 1 call site
*
* Types inlined from this project
* Band ← Band src/classify.ts:1 type, 1 reference, with `classify`
* Score ← Score src/classify.ts:3-6 type, 2 references, above `describeScore`
*
* External — type only, source never read
* Math lib.es5.d.ts Math
* floor lib.es5.d.ts (x: number) => number
*
* This artifact is for reading. Identifiers from other modules are left as
* written, so it is not guaranteed to compile or run.
*/
// Score ← src/classify.ts:3-6 (`Score`)
type Score = {
readonly label: string;
readonly value: number;
};
function describeScore(score: Score): string {
// Band ← src/classify.ts:1 (`Band`)
type Band = "invalid" | "excellent" | `band-${number}`;
// classify ← src/classify.ts:8-14 (`classify`)
function classify(score: Score): Band {
if (score.value < 0) {
return "invalid";
}
const band = Math.floor(score.value / 10);
return band >= 9 ? "excellent" : `band-${band}`;
}
return `${score.label} is ${classify(score)}`;
}The shapes travel with the code. Score is named by the target's own
signature, so it lands above the function — a parameter annotation cannot see
a declaration made inside the body. Band is only classify's business, so
it arrives next to classify.
Usage
The package is @hypercrab/roll-in; the command it installs is roll-in.
npm install -g @hypercrab/roll-inroll-in <file>:<line>:<col>
roll-in <file>:<line>
roll-in <file>#<name>
roll-in <file> <name>| Option | |
| --- | --- |
| --depth <n> | How far to follow calls and type references. Default 3. |
| --project <p> | tsconfig.json to use. Default: discovered from <file>. |
| --out <p> | Write to a file instead of stdout. |
| --json | Emit the structured report instead of the rendered function. |
Exit codes: 0 fine, 1 could not resolve the target, 2 bad usage,
3 the emitted text failed its own parse check.
How it decides what to do
For each call it finds, roll-in resolves the callee through the TypeScript
type checker and picks one of three outcomes.
Substitute — when it is provably safe. The callee's body must be a single expression, and every argument must be an identifier or a literal, so nothing can be reordered or re-evaluated:
sum += /* src/lib/money.ts:1 */ (Math.round(amount * 100));Lift — everything else in-project. The callee arrives as a real named
definition at the top of the target's body, keeping the form it was written
in — a function stays a function, an arrow stays an arrow — and the call
site is retargeted to it. Evaluation order, return, await, yield and
recursion then all work by construction rather than by transformation.
Refuse — when it cannot be proven. The call is left exactly as written with a one-line reason inline and in the header:
last = /* roll-in: method call, depends on `this` */ tally.add(value);Anything resolving outside the project contributes its call-site-instantiated type to the header. Its source is never read.
Types
Type aliases and interfaces follow the same walk, bounded by the same
--depth. A type the target's signature names is emitted above the target;
a type only one lifted helper needs is emitted next to that helper; a type
that names other types pulls the whole chain along. References are retargeted
just like calls, so a carried Order still reads as Order everywhere —
unless the name was already taken, in which case see Names.
Classes, enums, namespaces, and interfaces declared more than once are not carried — each is a value or an ambiguity, not just a shape. They are located in the header with the reason, as are types from outside the project.
Names
Anything carried into the artifact keeps the name it was written under. The
one exception is a name that is already taken by the time it arrives — by a
binding in the target, by something inside another lifted definition, by an
identifier left standing because it was not carried, or by another lifted
definition of the same name. Then, and only then, it is emitted as name$1,
name$2, and so on:
const RATE = 3;
// RATE$1 ← src/tax.ts:1 (`RATE`)
const RATE$1 = 0.2;The counter is per name, so a clash between two RATEs says nothing about
what any other definition is called. A suffix in the output therefore means
exactly one thing: this name refers to something else somewhere above it.
The provenance comment above every definition says which file it came from.
Guarantees
- Never reads source from
node_modules,*.d.ts, or outside the project. - Never writes to your source tree.
- Deterministic: same input, byte-identical output.
- The emitted text is re-parsed before it is returned; parse errors are
reported in the header and set exit code
3. - Untouched code stays verbatim, comments and all.
Limits
The output is built to be read, not compiled or run. External values and uncarried type names are left dangling by design, module-level mutable state is flagged rather than carried, and lifted constants and types are copies of the originals under new names.
CAVEATS.md is the full list, including what it refuses, what is not implemented, and where it deviates from the spec. Read it before relying on an artifact.
Claude Code skill
This repo doubles as a Claude Code plugin, so an agent can reach for roll-in
on its own when a question is about what a TypeScript function actually does
across modules — and knows to read the header first, treat refusals as
findings, and never mistake the artifact for source.
/plugin marketplace add hypercrab/roll-in
/plugin install roll-in@hypercrabOr, without the plugin, symlink the skill into your personal skills directory:
ln -s "$PWD/skills/roll-in" ~/.claude/skills/roll-inskills/roll-in/SKILL.md is the skill;
reference.md carries the refusal-code and
capture-kind tables it defers to. It resolves a binary through a local
install, a global install, $ROLL_IN_HOME, then npx, so it works in a
project that has never heard of roll-in.
Development
bun install # also builds dist/, via `prepare`
bun test # bun's own runner
bun run typecheck
bun run build # dist/ — what the package ships and what bin/ runsThe source is written with .ts import specifiers, but the package ships
compiled JavaScript in dist/ and bin/roll-in.js imports that. It cannot
import src/ directly: Node refuses to strip types for any file under
node_modules, so a source entry point runs fine from a checkout and fails
under npx, bunx and npm i -g alike.
The test corpus lives in tests/fixtures — 26 self-contained
mini-projects, one per semantic case, each with its own tsconfig.json and a
case.json describing what it pins down. tests/selfhost.test.ts runs the
tool over its own source, which is where most of the real bugs surfaced.
Built on the raw typescript package — ts.createProgram, a custom
CompilerHost, and ts.TypeChecker. No AST-manipulation wrapper, no bundler,
no second parser.
Pinned to typescript@^6. TypeScript 7 is the native compiler and exposes no
JS API — require("typescript") there returns only version — so it is a
hard ceiling rather than a pending upgrade. See CAVEATS.md.
