@kamaalio/codemods 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamaal Farah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,363 @@
1
+ # Codemods
2
+
3
+ A collection of codemods, runnable as a single CLI.
4
+
5
+ - [Codemods](#codemods)
6
+ - [Available codemods](#available-codemods)
7
+ - [Usage](#usage)
8
+ - [Flags](#flags)
9
+ - [Examples](#examples)
10
+ - [Config](#config)
11
+ - [joi-to-zod](#joi-to-zod)
12
+ - [What it transforms](#what-it-transforms)
13
+ - [Example](#example)
14
+ - [Current constraints](#current-constraints)
15
+ - [Library usage](#library-usage)
16
+ - [Development](#development)
17
+ - [Contributing](#contributing)
18
+ - [License](#license)
19
+
20
+ ## Available codemods
21
+
22
+ | Codemod | What it does |
23
+ | ------------ | ---------------------------------------------------------- |
24
+ | `joi-to-zod` | Rewrite supported Joi schema patterns into Zod equivalents |
25
+
26
+ `codemods list` prints the same table from the registry.
27
+
28
+ ## Usage
29
+
30
+ Run it without installing anything:
31
+
32
+ ```bash
33
+ npx @kamaalio/codemods joi-to-zod ./src
34
+ ```
35
+
36
+ Or install it globally:
37
+
38
+ ```bash
39
+ npm install -g @kamaalio/codemods
40
+ codemods joi-to-zod ./src
41
+ ```
42
+
43
+ The CLI takes the codemod name as its command, followed by a path:
44
+
45
+ ```bash
46
+ codemods <codemod> [PATH] [FLAGS]
47
+ ```
48
+
49
+ `PATH` may be a file or a directory, and defaults to `.`.
50
+
51
+ ### Flags
52
+
53
+ | Flag | Default | Description |
54
+ | ---------- | ------- | ------------------------------------------------------------------------------- |
55
+ | `--dry` | `false` | Print what would change without writing files |
56
+ | `--no-log` | `false` | Disable log output |
57
+ | `--config` | — | Path to a JSON config file listing the paths to migrate (see [Config](#config)) |
58
+
59
+ `--config` and `PATH` are mutually exclusive: pass one or the other, not both.
60
+
61
+ Each flag also accepts a short form (`-d`, `-n`, `-c`) and its uppercase alias (`-D`, `-N`, `-C`).
62
+
63
+ ### Examples
64
+
65
+ ```bash
66
+ # Transform the current directory
67
+ codemods joi-to-zod
68
+
69
+ # Transform a specific directory
70
+ codemods joi-to-zod src
71
+
72
+ # Transform a single file
73
+ codemods joi-to-zod src/schemas.ts
74
+
75
+ # Preview changes without writing them
76
+ codemods joi-to-zod src --dry
77
+
78
+ # Run quietly
79
+ codemods joi-to-zod src --no-log
80
+
81
+ # Transform the paths listed in a config file
82
+ codemods joi-to-zod --config joi-migration-phase1.json
83
+
84
+ # List the available codemods
85
+ codemods list
86
+ ```
87
+
88
+ ### Config
89
+
90
+ For larger or staged migrations, pass `--config` with a JSON file listing the paths to transform instead of a single `PATH`:
91
+
92
+ ```json
93
+ {
94
+ "paths": ["src/controllers"]
95
+ }
96
+ ```
97
+
98
+ Each entry in `paths` is transformed the same way a positional `PATH` argument would be. The config file can also set `dry_run` to default that run to dry-run mode, without needing `--dry` on the command line, and `log` to control log output, without needing `--no-log`:
99
+
100
+ ```json
101
+ {
102
+ "paths": ["src/controllers"],
103
+ "dry_run": true,
104
+ "log": false
105
+ }
106
+ ```
107
+
108
+ Passing both `--dry` and a config `dry_run` at the same time is an error — pick one. The same applies to `--no-log` and a config `log` — pick one.
109
+
110
+ ## joi-to-zod
111
+
112
+ Rewrites supported Joi schema patterns into Zod equivalents. It only touches files that use a default Joi import:
113
+
114
+ ```ts
115
+ import Joi from 'joi';
116
+ ```
117
+
118
+ If a file does not match that shape, it is ignored.
119
+
120
+ ### What it transforms
121
+
122
+ The codemod pipeline currently covers these Joi-to-Zod rewrites:
123
+
124
+ - Adds `import { z } from "zod"` when needed, and removes the `joi` import once the file no longer references it.
125
+
126
+ **Structure**
127
+
128
+ - `Joi.object().keys({...})` -> `z.object({...}).strict()`
129
+ - `Joi.array().items(schema)` -> `z.array(schema)`
130
+ - `Joi.alternatives().try(a, b)` -> `z.union([a, b])`
131
+ - `Joi.object().pattern(key, value)` -> `z.record(key, value)`
132
+ - `Joi.binary()` -> `z.instanceof(Buffer)`
133
+ - `schema.concat(other)` -> `z.intersection(schema, other)`
134
+ - `Joi.forbidden()` -> `z.never()`
135
+ - `.valid(...)` -> `z.enum(...)`, or `z.literal(...)` for a non-string primitive
136
+ - `.required()` / its absence -> required and `.optional()` object keys
137
+
138
+ **String formats** are emitted as Zod 4 top-level schemas, replacing the primitive rather
139
+ than chaining onto it, because `z.string().hex()` and friends do not exist in Zod 4. This
140
+ holds wherever the format sits in the chain, so `Joi.string().min(6).hex()` becomes
141
+ `z.hex().min(6)`:
142
+
143
+ - `guid` -> `z.uuid()`, `uri` -> `z.url()`, `email` -> `z.email()`, `domain` -> `z.hostname()`
144
+ - `hex` -> `z.hex()`, `base64` -> `z.base64()`
145
+ - `isoDate` -> `z.iso.datetime()`, `isoDuration` -> `z.iso.duration()`
146
+
147
+ **Dates** become coercing schemas, since Joi accepts ISO strings where `z.date()` would not:
148
+
149
+ - `Joi.date()` -> `z.coerce.date()`; `.iso()` and `.timestamp()` are dropped as redundant
150
+ - `.min('2020-01-01')` -> `.min(new Date('2020-01-01'))`, `.max('now')` -> `.max(new Date())`
151
+ - `.greater` / `.less` -> `.min` / `.max`
152
+
153
+ **Validations that need composed Zod.** Where Joi has no single Zod counterpart, the
154
+ codemod composes one rather than leaving the call behind:
155
+
156
+ - `alphanum` -> `regex(/^[a-zA-Z0-9]+$/)` (both cases, matching Joi), `token` -> `regex(/^\w+$/)`
157
+ - `precision(n)` -> `transform(value => Number(value.toFixed(n)))`, matching Joi's rounding
158
+ - `port()` -> `int().min(0).max(65535)`, `sign('positive')` -> `positive()`
159
+ - `Joi.array().unique()` -> `refine(value => new Set(value).size === value.length)`
160
+ - `Joi.object()` peer rules `and` / `or` / `xor` / `oxor` / `nand` / `with` / `without` -> `refine(...)`
161
+ - `Joi.object().min(n)` / `.max(n)` / `.length(n)` -> `refine` over `Object.keys(value).length`
162
+ - `invalid(...)` / `disallow(...)` -> `refine(value => ![...].includes(value))`
163
+ - `ip()` -> a refine over `z.ipv4()` and `z.ipv6()`
164
+
165
+ **Direct mappings**
166
+
167
+ - `integer` -> `int`, `greater` / `less` -> `gt` / `lt`, `multiple` -> `multipleOf`
168
+ - `description` / `label` -> `describe`, `allow(null)` -> `nullable`, `required(false)` -> `optional`
169
+ - `unknown(true)` / `unknown(false)` -> `passthrough()` / `strict()`
170
+ - `lowercase` / `uppercase` / `case(...)` -> `toLowerCase()` / `toUpperCase()`
171
+ - `pattern(...)` -> `regex(...)`, `failover` -> `catch`, `bool()` -> `boolean()`
172
+ - Annotation-only calls (`meta`, `tag`, `note`, `example`, `raw`, `cast`, `prefs`) are dropped
173
+
174
+ **Conditionals and callbacks.** A Joi conditional lives on the property but needs the whole
175
+ object to evaluate, so it is lifted to an object-level refinement and the property becomes
176
+ optional, with presence enforced by the refinement instead:
177
+
178
+ ```ts
179
+ // before
180
+ detail: Joi.string().when('type', { is: 'a', then: Joi.required(), otherwise: Joi.forbidden() });
181
+
182
+ // after
183
+ detail: z.string().optional();
184
+ // ...on the object:
185
+ .refine(value => !(value['type'] === 'a') || value['detail'] !== undefined, { path: ['detail'] })
186
+ .refine(value => (value['type'] === 'a') || value['detail'] === undefined, { path: ['detail'] })
187
+ ```
188
+
189
+ - `.when()` handles `is` as a literal, a `Joi.ref(...)`, or a schema, and `then` / `otherwise`
190
+ as `required()`, `optional()`, `forbidden()`, or a full schema. A bare schema `is` also
191
+ matches an absent key, because a Joi schema is optional unless it says otherwise.
192
+ - `.assert(subject, schema, message?)` -> a refinement comparing against the referenced key,
193
+ or parsing the subject against the schema.
194
+ - `.custom(fn)` -> `.transform(fn)`. When the callback uses Joi's `helpers`, it is kept
195
+ verbatim and handed a shim mapping `helpers.error` / `helpers.message` onto Zod's `ctx`.
196
+
197
+ **Flagged for manual migration.** What is left has no mechanical equivalent, so the codemod
198
+ leaves a `TODO(joi-to-zod)` comment naming the Zod construct to reach for:
199
+
200
+ - `.when(...)` using `switch`, `not`, or `break`, or applied outside an object property
201
+ - `.custom(...)` whose callback needs helpers beyond `error` and `message`, or which is
202
+ followed by calls that a transform would remove (`z.string().transform(f).min` does not exist)
203
+ - `.assert(...)` whose subject is not a plain reference
204
+
205
+ ### Example
206
+
207
+ Input:
208
+
209
+ ```ts
210
+ import Joi from 'joi';
211
+
212
+ enum MemberStatus {
213
+ Active = 'active',
214
+ Inactive = 'inactive',
215
+ }
216
+
217
+ export const memberSchema = Joi.object().keys({
218
+ id: Joi.alternatives().try(Joi.string(), Joi.number()).required(),
219
+ status: Joi.string()
220
+ .valid(...Object.values(MemberStatus))
221
+ .required(),
222
+ website: Joi.string().uri(),
223
+ metadata: Joi.object().pattern(Joi.string(), Joi.number()),
224
+ });
225
+ ```
226
+
227
+ Output:
228
+
229
+ ```ts
230
+ import { z } from 'zod';
231
+
232
+ enum MemberStatus {
233
+ Active = 'active',
234
+ Inactive = 'inactive',
235
+ }
236
+
237
+ export const memberSchema = z
238
+ .object({
239
+ id: z.union([z.string(), z.number()]),
240
+ status: z.enum(MemberStatus),
241
+ website: z.url().optional(),
242
+ metadata: z.record(z.string(), z.number()).optional(),
243
+ })
244
+ .strict();
245
+ ```
246
+
247
+ The codemod does not format its output. Run your formatter over the changed files afterwards.
248
+
249
+ ### Current constraints
250
+
251
+ - The codemod only targets files with a default `import Joi from 'joi'`.
252
+ - The AST language is configured as TypeScript, so this project is best suited to TypeScript-style source files.
253
+ - Coverage is driven by the rules and tests in [`src/codemods/joi-to-zod`](./src/codemods/joi-to-zod) and [`test/codemods/joi-to-zod`](./test/codemods/joi-to-zod). Patterns outside those rules may remain unchanged.
254
+ - `precision(n)` reproduces Joi's default rounding behaviour. A source schema validated with `convert: false` rejects imprecise input instead of rounding it, and the generated Zod will not match that.
255
+ - [`example/`](./example) is a live before/after fixture: CI type-checks, lints, and runs its behavioural tests against the Joi source, transforms it in place, then runs all three again against the generated Zod.
256
+ - The codemod migrates schema declarations. Consumers of Joi's `schema.validate()` result shape and framework-specific schema contracts, such as Hapi route validation, require a manual migration to Zod's parsing APIs.
257
+ - The tool is a codemod, not a semantic migration assistant. Review the output before committing.
258
+
259
+ ## Library usage
260
+
261
+ The package has a small programmatic API for embedding the Joi-to-Zod migration in your own tooling. It does not write files unless you use the CLI entry point; the transformer functions operate on source strings.
262
+
263
+ ### Transform a source string
264
+
265
+ Use the default export when you want the transformed source. Pass a filename when available so custom tooling can retain it in transformation metadata.
266
+
267
+ ```ts
268
+ import joiToZod from '@kamaalio/codemods';
269
+
270
+ const source = "import Joi from 'joi';\n\nexport const id = Joi.string().required();\n";
271
+ const transformed = await joiToZod(source, 'src/schema.ts');
272
+
273
+ // import { z } from "zod";
274
+ //
275
+ // export const id = z.string();
276
+ ```
277
+
278
+ Files without a default `Joi` import are returned unchanged.
279
+
280
+ ### Inspect transformation details
281
+
282
+ Use `joiToZodTransformer` when you need the AST, number of edits, or transformation history in addition to the generated source.
283
+
284
+ ```ts
285
+ import { joiToZodTransformer } from '@kamaalio/codemods';
286
+
287
+ const source = "import Joi from 'joi';\n\nexport const id = Joi.string().required();\n";
288
+ const result = await joiToZodTransformer(source, 'src/schema.ts');
289
+
290
+ console.log(result.report.changesApplied);
291
+ const transformed = result.ast.root().text();
292
+ ```
293
+
294
+ ### Integrate with a codemod runner
295
+
296
+ `JOI_TO_ZOD_CODEMOD` is the complete codemod definition, including its name, supported language, and string transformer. `JOI_TO_ZOD_LANGUAGE` is the corresponding ast-grep language constant.
297
+
298
+ ```ts
299
+ import { JOI_TO_ZOD_CODEMOD, JOI_TO_ZOD_LANGUAGE } from '@kamaalio/codemods';
300
+
301
+ const source = "import Joi from 'joi';\n\nexport const id = Joi.string().required();\n";
302
+ const transformed = await JOI_TO_ZOD_CODEMOD.transformer(source, 'src/schema.ts');
303
+ console.log(JOI_TO_ZOD_LANGUAGE, transformed);
304
+ ```
305
+
306
+ ### Invoke the CLI from JavaScript
307
+
308
+ `run` accepts the same arguments as the `codemods` executable. It logs to the console and reports command failures through `process.exitCode`.
309
+
310
+ ```ts
311
+ import { run } from '@kamaalio/codemods';
312
+
313
+ await run(['joi-to-zod', 'src', '--dry']);
314
+ ```
315
+
316
+ ## Development
317
+
318
+ Use `yarn` on Node.js `22` (see [`.nvmrc`](./.nvmrc)). Yarn 4 is activated through Corepack:
319
+
320
+ ```bash
321
+ corepack enable
322
+ yarn install
323
+ yarn build
324
+ yarn test
325
+ ```
326
+
327
+ Every task lives in `package.json` — there is no task runner to install:
328
+
329
+ | Script | What it does |
330
+ | ------------------------- | ---------------------------------------------------- |
331
+ | `yarn bootstrap` | Install dependencies from the lockfile |
332
+ | `yarn build` | Compile `src/` to `dist/` with `tsc` |
333
+ | `yarn clean:build` | Remove `dist/` and rebuild |
334
+ | `yarn test` | Run the test suite once |
335
+ | `yarn test:watch` | Run the test suite in watch mode |
336
+ | `yarn test:cov` | Run the test suite with coverage |
337
+ | `yarn test:u` | Update snapshots |
338
+ | `yarn test:example` | Run the `example/` behavioural tests |
339
+ | `yarn type-check` | Type-check `src/` |
340
+ | `yarn type-check:test` | Type-check tests and scripts |
341
+ | `yarn type-check:example` | Type-check `example/` |
342
+ | `yarn lint` | Lint with rslint |
343
+ | `yarn format` | Format with prettier |
344
+ | `yarn format:check` | Check formatting |
345
+ | `yarn quality` | Lint, format check, and both type checks |
346
+ | `yarn preview` | Run the CLI against `test/resources` in dry-run mode |
347
+ | `yarn transform:example` | Run the CLI against `example/` |
348
+ | `yarn new:codemod <name>` | Scaffold a new codemod |
349
+ | `yarn release <version>` | Publish to npm |
350
+
351
+ The release workflow uses npm trusted publishing through GitHub Actions. To
352
+ bootstrap the first release, add a write-capable `NPM_TOKEN` repository secret
353
+ temporarily. The release script passes it only to Yarn's npm authentication.
354
+ After the package exists, configure `kamaal111/codemods` and `release.yml` as
355
+ its trusted publisher in npm, then remove the secret; future releases use OIDC.
356
+
357
+ ## Contributing
358
+
359
+ Adding a codemod takes about ten minutes. See [CONTRIBUTING.md](./CONTRIBUTING.md).
360
+
361
+ ## License
362
+
363
+ MIT. See [LICENSE](./LICENSE).
package/bin/dev.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+
3
+ node "%~dp0\dev" %*
package/bin/dev.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from '../src/cli.ts';
4
+
5
+ await run();
package/bin/run.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+
3
+ node "%~dp0\run" %*
package/bin/run.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from '../dist/cli.js';
4
+
5
+ await run();
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@kamaalio/codemods",
3
+ "version": "0.0.1",
4
+ "description": "A collection of codemods, runnable as a single CLI.",
5
+ "keywords": [
6
+ "codemod",
7
+ "ast-grep",
8
+ "joi",
9
+ "zod",
10
+ "migration"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Kamaal Farah",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/kamaal111/codemods.git"
17
+ },
18
+ "type": "module",
19
+ "bin": "./bin/run.mjs",
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "bin"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.0.0"
31
+ },
32
+ "packageManager": "yarn@4.18.0",
33
+ "scripts": {
34
+ "bootstrap": "yarn install --immutable",
35
+ "build": "yarn run compile",
36
+ "compile": "tsc",
37
+ "clean:build": "rm -rf dist tsconfig.tsbuildinfo && yarn run build",
38
+ "test": "rstest run",
39
+ "test:watch": "rstest",
40
+ "test:cov": "rstest run --coverage",
41
+ "test:u": "rstest run --update",
42
+ "test:example": "rstest run --config example/rstest.config.ts example/",
43
+ "type-check": "tsc --noEmit",
44
+ "type-check:test": "tsc --noEmit --project tsconfig.test.json",
45
+ "type-check:example": "tsc --noEmit --project example/tsconfig.json",
46
+ "lint": "rslint",
47
+ "lint:example": "rslint example/",
48
+ "format": "prettier --write .",
49
+ "format:check": "prettier --check .",
50
+ "quality": "yarn run lint && yarn run format:check && yarn run type-check && yarn run type-check:test && yarn run check:example-snapshot",
51
+ "preview": "node ./bin/dev.mjs joi-to-zod test/resources --dry",
52
+ "transform:example": "node ./scripts/cli-entry.ts joi-to-zod example",
53
+ "generate:example-snapshot": "node ./scripts/generate-example-snapshot.ts --write",
54
+ "check:example-snapshot": "node ./scripts/generate-example-snapshot.ts --check",
55
+ "new:codemod": "node ./scripts/new-codemod.ts",
56
+ "release": "node ./scripts/publish.ts",
57
+ "prepare": "husky"
58
+ },
59
+ "dependencies": {
60
+ "@ast-grep/napi": "^0.45.3",
61
+ "fast-glob": "^3.3.3",
62
+ "neverthrow": "^8.2.0",
63
+ "zod": "^4.5.4"
64
+ },
65
+ "devDependencies": {
66
+ "@rslint/core": "^0.9.1",
67
+ "@rstest/core": "^0.11.12",
68
+ "@rstest/coverage-istanbul": "0.11.12",
69
+ "@types/node": "^26.4.1",
70
+ "husky": "^9.1.7",
71
+ "joi": "^18.2.8",
72
+ "lint-staged": "^17.5.0",
73
+ "prettier": "^3.9.6",
74
+ "typescript": "^7.0.2"
75
+ },
76
+ "lint-staged": {
77
+ "*.{js,mjs,cjs,ts}": "yarn lint",
78
+ "*": "prettier --write --ignore-unknown"
79
+ }
80
+ }