safemods
v0.1.3
Published
Type-directed codemods for TypeScript 7+ projects, built on Effect.
Readme
safemods
Type-directed codemods for TypeScript 7+ projects, built on Effect.
safemods uses the TypeScript 7 type checker to find exact symbol references across renamed imports, re-exports, and barrel files. Transformations produce range-bounded edits that are validated against type-checking and idempotency policies before anything is written to disk.
Query → Draft → Plan → Preview → Verify → ApplyRequirements
- TypeScript: 7.0 or higher (minimum requirement)
- Node.js: 24 or higher
Installation
pnpm add -D safemods effect typescript@7effect and typescript (v7+) are peer dependencies.
Example Recipe
A recipe finds nodes in your project, drafts replacements, and declares verification policies.
import { Effect } from "effect"
import { isObjectLiteralExpression } from "typescript/unstable/ast/is"
import {
ConfiguredProject,
Draft,
Policy,
Query,
Recipe,
WorkspaceSnapshot,
} from "safemods"
const app = ConfiguredProject.make({
id: "app",
config: "tsconfig.json",
})
export default Recipe.define("wrap-target-input", {
version: "1.0.0",
policies: [
Policy.matches({ min: 1 }),
Policy.noNewErrors(),
Policy.idempotent(),
],
run: () =>
Effect.gen(function* () {
const snapshot = yield* WorkspaceSnapshot
const project = yield* snapshot.project(app)
const target = yield* project.symbolNamed("target", {
within: "src/library.ts",
})
const calls = yield* Query.calls(project).pipe(
Query.where(
Query.resolvesTo(target, {
location: (call) => call.expression,
}),
),
Query.filter(
({ value: call }) =>
call.arguments.length === 1 &&
!isObjectLiteralExpression(call.arguments[0]!),
),
Query.collect,
)
return yield* Draft.replaceEach(calls, ({ value: call }) => {
const argument = call.arguments[0]!
return {
node: argument,
text: `{ value: ${argument.getText()} }`,
}
})
}),
})CLI
By default, safemods run previews proposed diffs. Use --verify to run policy checks, or --apply to write changes once verified.
# Preview diff in terminal
safemods run ./recipe.ts --cwd ./my-project
# Run verification policies without writing
safemods run ./recipe.ts --cwd ./my-project --verify
# Verify and apply changes to disk
safemods run ./recipe.ts --cwd ./my-project --applyIf a recipe defines an input Schema, pass parameters via --input:
safemods run ./recipe.ts --cwd ./my-project --input '{"key": "value"}'To export the recipe's input schema:
safemods tool ./recipe.tsCore Concepts
- Exact range edits: Changes target source ranges and verify old-text hashes, leaving surrounding formatting and comments untouched.
- Symbol renames:
Draft.renameSymbolautomatically updates declarations, references, and alias imports across the project. - In-memory overlays:
Recipe.pipechains recipes in memory, allowing subsequent steps to query against modified ASTs before writing to disk. - Policies:
Policy.matches({ min: 1 })— Fails if the query matched no call sites.Policy.noNewErrors()— Checks that the proposed diff introduces no new compiler diagnostics.Policy.idempotent()— Verifies that re-running the recipe against transformed code yields zero further edits.
Programmatic Usage
Recipes can also be executed directly within an Effect workflow:
import { Recipe, Preview, Verification, Application } from "safemods"
const plan = yield* Recipe.run(recipe, input)
const preview = yield* Preview.of(plan)
const verified = yield* Verification.verify(plan, recipe, input)
const receipt = yield* Application.apply(verified)Development
pnpm check
pnpm testExamples & References
- API Tour — Comprehensive walkthrough of available queries and combinators.
- Examples — Sample recipes for renames, migrations, and calls.
- Context & Architecture — Project terminology and architectural model.
