# @sdxc/codemod

Source-to-source edits of JavaScript and TypeScript imports and declarations, changing only the edited ranges.

## Installation

```sh
npm add @sdxc/codemod
```

Every function answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which installs alongside this package. There is no other runtime dependency: the scanner is plain JavaScript and runs on Node, Bun, Deno or a Worker.

## Usage

### Find every module a file references

```typescript
import { findModuleReferences } from "@sdxc/codemod";
import { isFailure } from "@sdxc/result";

let references = findModuleReferences("page.tsx", source, { calls: ["vi.mock"] });
if (isFailure(references)) throw references.error; // "page.tsx:12:8: string is never closed"

for (let reference of references.data) {
	reference.kind; // "import" | "export" | "dynamic" | "call"
	reference.specifier; // "./button.js"
	reference.names; // [{ imported: "Button", local: "Button", typeOnly: false, range }]
}
```

The file name's extension decides what the scanner reads: `.js`, `.mjs`, `.cjs` and `.jsx` as JavaScript with JSX, `.ts`, `.mts` and `.cts` as TypeScript, and `.tsx` as TypeScript with JSX. A `<` where an expression begins is an element in every JavaScript file, so a `.js` component reads the same as a `.jsx` one, and `a < b` stays a comparison. Strings, template literals, regular expressions, comments and JSX text are tokens of their own, so `<p>Don't import "./x.js"</p>` names no module.

### Rewrite specifiers

```typescript
import { rewriteSpecifiers } from "@sdxc/codemod";

let rewritten = rewriteSpecifiers("dialog.tsx", source, (reference) =>
	reference.specifier.startsWith("@acme/") ? reference.specifier.replace("@acme/", "acme/") : null,
);
// success({ source: "…", changed: true, unhandled: [] })
```

Only the string literals that change are replaced, each in its original quote character.

### Move imports to another module

```typescript
import { moveImports } from "@sdxc/codemod";

let moved = moveImports("page.tsx", 'import { Button, Dialog } from "acme/ui";\n', {
	from: "acme/ui",
	names: ["Button"],
	to: "~/components/ui/button.js",
});
// import { Dialog } from "acme/ui";
// import { Button } from "~/components/ui/button.js";
```

Name `"default"` to move a default import and `"*"` to move `import * as ui`, `export *` and `export * as ui`.

### Add to a declaration object and to a sequence of calls

```typescript
import { addProperty, insertAfterLast } from "@sdxc/codemod";

let declared = addProperty("index.ts", source, {
	call: "jobs",
	path: ["trials"],
	key: "expire",
	value: "job({ input: s.object({}) })",
});

let mapped = insertAfterLast("dispatcher.ts", source, {
	call: "dispatcher.map",
	statement: 'dispatcher.map(jobs.trials.expire, () => import("./jobs/expire.js"));',
	fallbackAfter: "createJobDispatcher",
});
```

## API

### `findModuleReferences(filename: string, source: string, options?: FindOptions): Result<ModuleReference[], CodemodError>`

Every static import and side-effect import, every `export … from`, every `import()` whose argument is a string literal, and the first argument of each call whose callee is listed in `options.calls` (dotted paths such as `vi.mock`, or `require`), in source order. These are the specifiers a module loader resolves: imports and re-exports at the top level, where ECMAScript allows them, and `import()` in expressions. TypeScript import types (`typeof import("…")`, `import("…").Name`) and `import x = require("…")` belong to the type checker and to CommonJS, and stay out of the list unless `options.importTypes` is set, which reports them as `dynamic` references with `typeOnly` set, the way a declaration file names its dependencies.

### `rewriteSpecifiers(filename, source, map, options?): Result<Edit, CodemodError>`

Replaces the specifier of every reference `map` answers a string for; `null` leaves it untouched. `options.calls` adds listed calls to the references `map` sees.

### `moveImports(filename, source, options: MoveOptions): Result<Edit, CodemodError>`

Moves the imports and re-exports of `options.names` from `options.from` to `options.to`. A name is matched as the module exports it, whatever alias a declaration gives it: a named binding by its name, a default binding by `"default"`, and a namespace (`import * as ui`, `export * as ui`) or a bare `export *` by `"*"`.

- A declaration whose bindings all move has its specifier replaced in place.
- Otherwise the declaration keeps the rest, and the moved bindings go to `options.to`, keeping aliases, `type` modifiers, `import type`, the quote character and the semicolon style.
- Every moved binding of one kind (imports or re-exports, type-only or not) lands in one declaration of `options.to`: an existing one, the first one repointed in place, or one new declaration. `import { A } from "x"` and `import { B } from "x"` both moving become `import { A, B } from "y"`, and a default joins the names (`import Ui, { A } from "y"`). A namespace, a type-only default and a second default keep declarations of their own, and `import type` stays apart from `import`.
- A namespace import or `export *` of `options.from` that stays while other names move reaches those names through the whole module. It is returned in `unhandled` and left as it is.

### `addProperty(filename, source, options: AddPropertyOptions): Result<Edit, CodemodError>`

Appends `key: value` to the object literal passed as the first argument of the first call to `options.call`. `options.path` walks nested object literals by key; a value that is a call, such as `route("/prefix", { … })`, is walked through its object argument, and a missing level is created. The property follows the last one with its siblings' indentation and trailing-comma style. An existing key fails with `duplicate-key`.

### `insertAfterLast(filename, source, options: InsertOptions): Result<Edit, CodemodError>`

Places `options.statement` on a new line after the last statement that calls `options.call`, at that statement's indentation. With no such call, it follows the variable declaration whose initializer calls `options.fallbackAfter`; with neither, it fails with `not-found`.

`value` and `statement` are source text: every line after the first is indented to match the surrounding code, and nothing else about them changes. Run the project's formatter afterwards for anything beyond indentation.

### `Edit`

`{ source, changed, unhandled }`: the edited text (the input itself when nothing `changed`), and for `moveImports` the namespace imports and `export *` declarations it left on the old module. Every edit scans its own output again and fails with `invalid-output` instead of returning source that does not scan.

### `CodemodError`

The failure every function reports. Input that ends inside a string, template literal, regular expression, comment or JSX element, unbalanced brackets, or an import declaration outside the grammar fail before any edit is computed. The fields are:

- `code`: `"unsupported-file"`, `"unterminated"`, `"unbalanced"`, `"unexpected-character"`, `"invalid-declaration"`, `"not-found"`, `"duplicate-key"` or `"invalid-output"`.
- `filename`: the name the caller passed.
- `offset`: the UTF-16 offset the failure concerns, or `null`; `message` carries it as `line:column`.

### Types

#### `ModuleReference`

`{ kind, specifier, typeOnly, names, range, specifierRange, callee, braces }`. `range` covers the whole declaration (its `;` included) or call; `specifierRange` covers the string literal, quotes included; `braces` covers the `{ … }` name list when there is one. Ranges are UTF-16 offsets, as `String.prototype.slice` reads them.

#### `BoundName`

`{ imported, local, typeOnly, range }`. `imported` is `"default"` for a default import and `"*"` for a namespace; for a re-export, `local` is the exported name.

## Patterns

### Pattern: Point a project at its own copy of a component

Move the component's import in every file, and report the imports that need a hand edit.

```typescript
import { readFile, writeFile } from "node:fs/promises";

import { moveImports } from "@sdxc/codemod";
import { isFailure } from "@sdxc/result";

async function adopt(files: string[], name: string, to: string) {
	for (let file of files) {
		let source = await readFile(file, "utf8");
		let edit = moveImports(file, source, { from: "acme/ui", names: [name], to });
		if (isFailure(edit)) {
			console.warn(`Change the import of ${name} in ${file} by hand: ${edit.error.message}`);
			continue;
		}
		for (let reference of edit.data.unhandled) {
			console.warn(`${file}: ${source.slice(reference.range.start, reference.range.end)}`);
		}
		if (edit.data.changed) await writeFile(file, edit.data.source);
	}
}
```

## Versioning

Releases are dated rather than semantic. A version is the UTC date it was published,
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
release goes out per day.

Those numbers say when, not what: a later date means a later release and carries no
compatibility promise. Any release may change or remove an export.

Depend on one exact date, and move it when you are ready to take the change:

```json
{
	"dependencies": {
		"@sdxc/codemod": "2026.9.4"
	}
}
```

A caret or tilde range reads the date as major, minor and patch, so it accepts every
later release in the same year. An exact version keeps the upgrade yours to schedule.

## License

MIT

## Author

[Sergio Xalambrí](https://sergiodxa.com)
