# Transforms > Typed data pipelines for filtering, normalizing, and partitioning scan output with step.transform. Recon and scanning produce messy text: duplicate subdomains, mixed case, blank lines, noise you did not ask for. BountyHub workflows treat data transformation as a first-class, **typed** pipeline primitive instead of a pile of `awk | sort | uniq` shell glue. ## The Shape of a Transform A transform is a **source** plus an ordered chain of operations: ```bhw step.transform( source = path.file("subs.txt").lines(), pipeline = transform.trim().lower().unique().sort(), destination = path.file("subs-clean.txt"), ) ``` - `source` — how to read the input: `path.file("subs.txt").lines()` parses the file as newline-delimited lines. Other sources: `.text()`, `.json()`, `.jsonl()`. - `pipeline` — the operations applied to each element (or the collection). - `destination` — where the result is written; must be `path.file(...)`. The equivalent explicit form passes the source separately: ```bhw step.transform( source = path.file("subs.txt"), pipeline = transform.lines().trim().lower().unique().sort(), destination = path.file("subs-clean.txt"), ) ``` Both forms compile to the same expression. Use whichever reads better to you. ## Sources
Source Parses input as Produces
path.file(f).lines() / transform.lines() Newline-delimited lines Items
path.file(f).text() / transform.text() Whole file as one string string
path.file(f).json() / transform.json() One JSON document json
path.file(f).jsonl() / transform.jsonl() Newline-delimited JSON records Items
`record` values support field access via `.field`, which is how JSONL output from tools like `httpx -jsonl` becomes structured data. Generic collection ops like `.unique()` work on any item type: ```bhw # Deduplicate identical httpx JSONL observations. step.transform( source = path.file("live.jsonl").jsonl(), pipeline = transform.unique(), destination = path.file("live-dedup.jsonl"), ) ``` A bare `path.file("x")` without a parse is just a file reference — transforms reject it ("raw file"). Always choose a source first. ## Operations Operations are chained fluently and are statically type-checked. ### Element-wise string ops Applied to every item:
Op Effect
.trim() Strip surrounding whitespace
.lower() / .upper() Change case
.replace(from, to) Replace substring
.regex(pattern) Extract regex match (optionally a capture group)
.regex_replace(pattern, replacement) Substitute regex matches
```bhw # Normalize subdomains and keep only the root domain part. step.transform( source = path.file("subs.txt").lines(), pipeline = transform.trim().lower(), destination = path.file("subs-clean.txt"), ) # Extract hosts from raw httpx output lines like "https://target.com [200]". step.transform( source = path.file("raw-httpx.txt").lines(), pipeline = transform.regex(r"https?://([^ \]]+)").unique(), destination = path.file("hosts.txt"), ) ``` ### Collection ops Applied to the whole stream:
Op Effect
.unique() Remove duplicates
.take(n) / .drop(n) Head/tail slicing
.sort() / .reverse() Order the stream
.split(sep) Split each item into multiple items
.join(sep) Join all items into one string
```bhw # Split comma-separated targets into individual lines. step.transform( source = path.file("targets.csv").lines(), pipeline = transform.trim().split(",").trim().lower().unique().sort(), destination = path.file("targets-clean.txt"), ) # Build an nuclei-friendly single-line argument from unique hosts. step.transform( source = path.file("hosts.txt").lines(), pipeline = transform.trim().unique().sort().join(","), destination = path.file("hosts-arg.txt"), ) ``` ### Lambda ops: `map` / `filter` / `flat_map` The only way to compute per-item values or drop records by predicate. Each takes an ordinary one-parameter function (lambda or named `def`), captured deferred and applied at runtime: ```bhw # Append a port to every host. step.transform( source = path.file("hosts.txt").lines(), pipeline = transform.map(lambda host: "{}:8080".format(host)), destination = path.file("hosts-8080.txt"), ) # Keep only high-severity JSONL records, then expand each into its URLs. step.transform( source = path.file("findings.jsonl").jsonl(), pipeline = transform.filter(lambda row: row.severity == "high").flat_map(lambda row: row.urls), destination = path.file("high-urls.txt"), ) ``` Named functions work too (`transform.map(add_port)` where `def add_port(host): ...`). Signatures are checked: `map` takes exactly one parameter. ### Execution model Ops fall into four execution kinds; knowing them predicts memory behavior:
Kind Ops Behavior
Element-wise (streaming) trim , lower , upper , replace , regex , regex_replace , map One item in, zero/one out; constant memory
Streaming selection/expansion filter , flat_map , split Zero or more out per item; constant memory
Stateful (streaming) take(n) , drop(n) , unique() Small buffered state; unique memory grows with the unique set
Barrier (buffering) sort , reverse , join(sep) Buffers the whole sequence before emitting
Prefer streaming ops for 50k-host lists; reserve `sort`/`reverse`/`join` for bounded outputs. ## Types Are Checked Statically Every value in a transform has a semantic type: `String`, `Number`, `Bool`, `File`, `Directory`, `Json`, `Record`, `Items`, `Batch` Each operation declares which types it accepts, so mistakes fail at save time, not at 3 AM mid-scan: ```bhw # ERROR: .lower() needs strings, but json() produces a Json document. transform.json().lower() # OK: .unique() works on any collection, including JSONL records. transform.jsonl().unique() ``` Collections serialize newline-delimited on write, so `Items` writes one value per line. ## Partitioning Large datasets can be split into partitions for bounded parallel execution. Partition strategies come from the `partition` namespace: - `partition.count(n)` — approximately `n` partitions; sizes differ by at most one. - `partition.items(n)` — at most `n` items per partition. ```bhw # Split 50k live hosts into batches of 500 for parallel nuclei runs. step.transform( source = path.file("live.txt").lines(), pipeline = transform.trim().unique().partition(partition.items(500)), destination = path.file("batch-{partition_seq}.txt"), ) ``` Rules: - A partitioned pipeline must use the `{partition_seq}` placeholder in its destination, so each partition gets its own file (`batch-0.txt`, `batch-1.txt`, ...). - Non-partitioned pipelines must **not** contain the placeholder. - `partition` is terminal — nothing can be chained after it. - `partition()` takes only a strategy: `partition.count(n)` or `partition.items(n)`. A bare integer is rejected. - Execution is deterministic and order-preserving; an empty dataset produces zero partitions. Pair partitioning with [`step.for_each`](/workflows/for-each) to process each batch in parallel. The `for_each` items can be a typed transform expression directly: ```bhw activity( name = "distributed-nuclei", triggers = [trigger.on(probe.succeeded())], steps = [ step.for_each( # Unique live hosts, split into ~20 balanced batches. items = path.file("live.txt").lines().unique().partition(partition.count(20)), do = lambda batch: [ # Materialize this batch as a file and scan it. step.exec( command = ["nuclei", "-l", batch.as_file(), "-severity", "critical", "-o", "findings.txt"], ), ], ), ], ) ``` Alternatively, write partitions to files first and fan out over them with `path.glob(base = "batches", pattern = "scan-*.txt")`. Records support field access (`row.url`, `row.severity`), method-style string ops on fields (`row.host.lower()`), and index access (`row.urls[0]`) inside `map`/`filter` lambdas and dataset mappings.