@omnimod/core 0.1.0

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 salnika
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,39 @@
1
+ # @omnimod/core
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40omnimod%2Fcore?label=npm)](https://www.npmjs.com/package/@omnimod/core)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/Salnika/omnimod/ci.yml?branch=master&label=ci)](https://github.com/Salnika/omnimod/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/Salnika/omnimod)](../../LICENSE)
6
+
7
+ Core engine for omnimod, a modular codemod tool built on `oxc-parser` and
8
+ `magic-string`.
9
+
10
+ This package provides the plugin contract, parsing, AST walking, project
11
+ orchestration, formatting hooks, and terminal diff rendering used by the CLI and
12
+ plugins.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pnpm add @omnimod/core
18
+ ```
19
+
20
+ ## API
21
+
22
+ ```ts
23
+ import { definePlugin, run, walk } from "@omnimod/core";
24
+ ```
25
+
26
+ Main exports:
27
+
28
+ - `definePlugin`: create a typed omnimod plugin.
29
+ - `run`: execute a plugin across a project.
30
+ - `parseFile`: parse JS, JSX, TS, and TSX files.
31
+ - `walk`: traverse ESTree-compatible AST nodes.
32
+ - `detectFixers` and `formatFiles`: run detected project formatters after writes.
33
+ - `renderDiff` and `renderEmitted`: render terminal output for changed and emitted files.
34
+
35
+ ## Plugin Lifecycle
36
+
37
+ Plugins can implement `analyze`, `transform`, and `finalize` phases. `analyze`
38
+ runs across all files first, `transform` applies edits per file, and `finalize`
39
+ can emit follow-up files such as migration notes.
@@ -0,0 +1,205 @@
1
+ import MagicString from "magic-string";
2
+
3
+ //#region src/format.d.ts
4
+ interface Fixer {
5
+ /** Display name, e.g. "prettier". */
6
+ tool: string;
7
+ /** Resolved absolute path of the binary. */
8
+ bin: string;
9
+ /** Build the argv for a batch of files. */
10
+ args: (files: string[]) => string[];
11
+ }
12
+ interface FormatResult {
13
+ /** Tools that were invoked (in order). */
14
+ tools: string[];
15
+ /** Non-fatal notes (formatting is best-effort; the files are already written). */
16
+ errors: string[];
17
+ }
18
+ /**
19
+ * Detect the project's own formatter/linter fixers so generated code matches the
20
+ * project's conventions (quotes, semicolons, indentation, import order, …).
21
+ * Biome is all-in-one; otherwise eslint --fix (lint) then prettier/dprint (format).
22
+ */
23
+ declare function detectFixers(root: string): Fixer[];
24
+ /**
25
+ * Run the project's detected fixers over the given files (best-effort — a fixer
26
+ * that exits non-zero, e.g. eslint with remaining lint errors, still counts as run).
27
+ */
28
+ declare function formatFiles(root: string, files: string[]): Promise<FormatResult>;
29
+ //#endregion
30
+ //#region src/types.d.ts
31
+ /**
32
+ * A parsed AST node. oxc-parser emits ESTree-compatible nodes; we type them
33
+ * structurally so plugins can navigate freely while keeping `type`/`start`/`end`
34
+ * strongly typed. Byte offsets are UTF-16 code-unit indices (JS string indices),
35
+ * so they can be fed directly to magic-string.
36
+ */
37
+ interface Node {
38
+ type: string;
39
+ start: number;
40
+ end: number;
41
+ [key: string]: unknown;
42
+ }
43
+ interface Program extends Node {
44
+ type: "Program";
45
+ body: Node[];
46
+ }
47
+ type Severity = "info" | "warn" | "error";
48
+ interface Diagnostic {
49
+ message: string;
50
+ severity: Severity;
51
+ /** Absolute path of the file the diagnostic refers to. */
52
+ file: string;
53
+ /** 1-based line number, when known. */
54
+ line?: number;
55
+ }
56
+ interface EmittedFile {
57
+ /** Path of the file to create (absolute, or relative to the run root). */
58
+ path: string;
59
+ contents: string;
60
+ }
61
+ /** Everything a plugin needs to inspect and rewrite a single file. */
62
+ interface FileContext {
63
+ /** Absolute path of the file being processed. */
64
+ path: string;
65
+ /** Original, unmodified source text. */
66
+ source: string;
67
+ /** Parsed program (oxc AST). */
68
+ program: Program;
69
+ /** Comments collected by the parser. */
70
+ comments: Node[];
71
+ /**
72
+ * Edit buffer. Mutate this via overwrite/appendLeft/remove to change the file;
73
+ * untouched regions keep their exact original formatting.
74
+ */
75
+ magic: MagicString;
76
+ /** Report a diagnostic (warning / manual follow-up). */
77
+ report(diagnostic: Omit<Diagnostic, "file">): void;
78
+ /** Emit a new generated file (e.g. a sibling `.css.ts`). */
79
+ emit(file: EmittedFile): void;
80
+ }
81
+ /** Shared, cross-file context. `state` is the plugin's own reduce target. */
82
+ interface ProjectContext<State = unknown> {
83
+ /** Absolute project root. */
84
+ root: string;
85
+ /** Plugin-defined shared store, typically populated during `analyze`. */
86
+ state: State;
87
+ /** Resolve a relative import specifier to an absolute path (best-effort). */
88
+ resolve(fromFile: string, specifier: string): string | null;
89
+ }
90
+ /**
91
+ * A codemod plugin. The lifecycle is a two-phase map/reduce so plugins can do
92
+ * cross-file work: `analyze` runs over every file first (collecting facts into
93
+ * `state`), then `transform` runs over every file (rewriting using that state),
94
+ * then `finalize` may emit shared files.
95
+ */
96
+ interface Plugin<Options = Record<string, unknown>, State = unknown> {
97
+ name: string;
98
+ description?: string;
99
+ /** Glob patterns of files to include (relative to root). */
100
+ include?: string[];
101
+ /** Glob patterns to exclude. */
102
+ exclude?: string[];
103
+ /** Create the initial shared state. Defaults to `{}`. */
104
+ createState?(): State;
105
+ /** Optional first pass: collect cross-file facts into `project.state`. */
106
+ analyze?(file: FileContext, project: ProjectContext<State>, options: Options): void | Promise<void>;
107
+ /** Main pass: mutate `file.magic` and/or `file.emit(...)`. */
108
+ transform(file: FileContext, project: ProjectContext<State>, options: Options): void | Promise<void>;
109
+ /** Optional final pass: emit shared files (e.g. `theme.css.ts`). */
110
+ finalize?(project: ProjectContext<State>, options: Options): EmittedFile[] | void;
111
+ }
112
+ interface RunOptions<Options = Record<string, unknown>> {
113
+ /** Absolute or relative project root to operate on. */
114
+ root: string;
115
+ /** Override the plugin's include globs. */
116
+ include?: string[];
117
+ /** Additional exclude globs. */
118
+ exclude?: string[];
119
+ /** When true, write changes to disk; otherwise this is a dry-run. */
120
+ write?: boolean;
121
+ /**
122
+ * Run the project's own formatter/linter `--fix` on the written files so output
123
+ * matches its conventions. Defaults to true; only runs in write mode.
124
+ */
125
+ format?: boolean;
126
+ /** Options forwarded to the plugin. */
127
+ options?: Options;
128
+ }
129
+ interface FileChange {
130
+ path: string;
131
+ before: string;
132
+ after: string;
133
+ }
134
+ interface RunResult {
135
+ /** Files whose contents changed. */
136
+ changed: FileChange[];
137
+ /** Newly created files. */
138
+ emitted: EmittedFile[];
139
+ /** Warnings and manual-follow-up notes. */
140
+ diagnostics: Diagnostic[];
141
+ /** Formatters run on the written files (write mode only). */
142
+ format?: FormatResult;
143
+ }
144
+ //#endregion
145
+ //#region src/parse.d.ts
146
+ interface ParsedFile {
147
+ program: Program;
148
+ comments: Node[];
149
+ /** Parser errors (oxc reports rather than throws). Empty when the file is valid. */
150
+ errors: unknown[];
151
+ }
152
+ type Lang = "js" | "jsx" | "ts" | "tsx";
153
+ /** Pick an oxc language mode from a file path. `.js`/`.jsx` allow JSX for safety. */
154
+ declare function langFromPath(path: string): Lang;
155
+ declare function parseFile(path: string, source: string): ParsedFile;
156
+ //#endregion
157
+ //#region src/walk.d.ts
158
+ type WalkResult = void | "skip";
159
+ /**
160
+ * Visitor callback. Return `"skip"` to avoid descending into a node's children.
161
+ * `key`/`index` describe where the node sits in its parent.
162
+ */
163
+ type Visitor = (node: Node, parent: Node | null, key: string | null, index: number | null) => WalkResult;
164
+ /**
165
+ * Depth-first walk over an oxc ESTree AST. Engine-agnostic: it recurses into any
166
+ * own property whose value is a node (has a string `type`) or an array of nodes.
167
+ */
168
+ declare function walk(root: Node, enter: Visitor, leave?: Visitor): void;
169
+ //#endregion
170
+ //#region src/define.d.ts
171
+ /**
172
+ * Identity helper for authoring a plugin. It changes nothing at runtime but
173
+ * gives full type inference and a single, discoverable entry point:
174
+ *
175
+ * ```ts
176
+ * import { definePlugin } from "@omnimod/core";
177
+ *
178
+ * export default definePlugin({
179
+ * name: "my-codemod",
180
+ * include: ["**\/*.ts"],
181
+ * transform(file) {
182
+ * // mutate file.magic / file.emit(...) / file.report(...)
183
+ * },
184
+ * });
185
+ * ```
186
+ */
187
+ declare function definePlugin<Options = Record<string, unknown>, State = unknown>(plugin: Plugin<Options, State>): Plugin<Options, State>;
188
+ //#endregion
189
+ //#region src/runner.d.ts
190
+ /** Best-effort resolution of a relative import specifier to an absolute file path. */
191
+ declare function resolveImport(fromFile: string, specifier: string): string | null;
192
+ /**
193
+ * Run a single plugin over a project: discover files, parse each once, then
194
+ * drive the analyze → transform → finalize lifecycle. Dry-run by default;
195
+ * pass `write: true` to persist changes and emitted files.
196
+ */
197
+ declare function run<Options, State>(plugin: Plugin<Options, State>, runOptions: RunOptions<Options>): Promise<RunResult>;
198
+ //#endregion
199
+ //#region src/diff.d.ts
200
+ /** A colored unified diff for a modified file. */
201
+ declare function renderDiff(change: FileChange): string;
202
+ /** A colored unified diff for a newly created file. */
203
+ declare function renderEmitted(file: EmittedFile): string;
204
+ //#endregion
205
+ export { type Diagnostic, type EmittedFile, type FileChange, type FileContext, type Fixer, type FormatResult, type Lang, type Node, type ParsedFile, type Plugin, type Program, type ProjectContext, type RunOptions, type RunResult, type Severity, type Visitor, type WalkResult, definePlugin, detectFixers, formatFiles, langFromPath, parseFile, renderDiff, renderEmitted, resolveImport, run, walk };
package/dist/index.mjs ADDED
@@ -0,0 +1,380 @@
1
+ import { parseSync } from "oxc-parser";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { globby } from "globby";
6
+ import MagicString from "magic-string";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { createTwoFilesPatch } from "diff";
10
+ import pc from "picocolors";
11
+ //#region src/parse.ts
12
+ /** Pick an oxc language mode from a file path. `.js`/`.jsx` allow JSX for safety. */
13
+ function langFromPath(path) {
14
+ if (path.endsWith(".tsx")) return "tsx";
15
+ if (path.endsWith(".ts") || path.endsWith(".mts") || path.endsWith(".cts")) return "ts";
16
+ return "jsx";
17
+ }
18
+ function parseFile(path, source) {
19
+ const result = parseSync(path, source, {
20
+ lang: langFromPath(path),
21
+ sourceType: "module"
22
+ });
23
+ return {
24
+ program: result.program,
25
+ comments: result.comments ?? [],
26
+ errors: result.errors ?? []
27
+ };
28
+ }
29
+ //#endregion
30
+ //#region src/walk.ts
31
+ const SKIP_KEYS = /* @__PURE__ */ new Set([
32
+ "type",
33
+ "start",
34
+ "end",
35
+ "range",
36
+ "loc",
37
+ "parent"
38
+ ]);
39
+ function isNode(value) {
40
+ return typeof value === "object" && value !== null && typeof value.type === "string";
41
+ }
42
+ /**
43
+ * Depth-first walk over an oxc ESTree AST. Engine-agnostic: it recurses into any
44
+ * own property whose value is a node (has a string `type`) or an array of nodes.
45
+ */
46
+ function walk(root, enter, leave) {
47
+ function visit(node, parent, key, index) {
48
+ if (enter(node, parent, key, index) === "skip") return;
49
+ for (const childKey in node) {
50
+ if (SKIP_KEYS.has(childKey)) continue;
51
+ const value = node[childKey];
52
+ if (Array.isArray(value)) for (let i = 0; i < value.length; i++) {
53
+ const child = value[i];
54
+ if (isNode(child)) visit(child, node, childKey, i);
55
+ }
56
+ else if (isNode(value)) visit(value, node, childKey, null);
57
+ }
58
+ leave?.(node, parent, key, index);
59
+ }
60
+ visit(root, null, null, null);
61
+ }
62
+ //#endregion
63
+ //#region src/define.ts
64
+ /**
65
+ * Identity helper for authoring a plugin. It changes nothing at runtime but
66
+ * gives full type inference and a single, discoverable entry point:
67
+ *
68
+ * ```ts
69
+ * import { definePlugin } from "@omnimod/core";
70
+ *
71
+ * export default definePlugin({
72
+ * name: "my-codemod",
73
+ * include: ["**\/*.ts"],
74
+ * transform(file) {
75
+ * // mutate file.magic / file.emit(...) / file.report(...)
76
+ * },
77
+ * });
78
+ * ```
79
+ */
80
+ function definePlugin(plugin) {
81
+ return plugin;
82
+ }
83
+ //#endregion
84
+ //#region src/format.ts
85
+ const execFileAsync = promisify(execFile);
86
+ const CHUNK_SIZE = 80;
87
+ const CONFIG_FILES = {
88
+ biome: ["biome.json", "biome.jsonc"],
89
+ prettier: [
90
+ ".prettierrc",
91
+ ".prettierrc.json",
92
+ ".prettierrc.json5",
93
+ ".prettierrc.yaml",
94
+ ".prettierrc.yml",
95
+ ".prettierrc.js",
96
+ ".prettierrc.cjs",
97
+ ".prettierrc.mjs",
98
+ ".prettierrc.ts",
99
+ ".prettierrc.toml",
100
+ "prettier.config.js",
101
+ "prettier.config.cjs",
102
+ "prettier.config.mjs",
103
+ "prettier.config.ts"
104
+ ],
105
+ dprint: [
106
+ "dprint.json",
107
+ ".dprint.json",
108
+ "dprint.jsonc"
109
+ ],
110
+ eslint: [
111
+ ".eslintrc",
112
+ ".eslintrc.js",
113
+ ".eslintrc.cjs",
114
+ ".eslintrc.json",
115
+ ".eslintrc.yaml",
116
+ ".eslintrc.yml",
117
+ "eslint.config.js",
118
+ "eslint.config.mjs",
119
+ "eslint.config.cjs",
120
+ "eslint.config.ts"
121
+ ]
122
+ };
123
+ /** Walk from `root` up to the filesystem root, calling `check` on each directory. */
124
+ function walkUp(root, check) {
125
+ let dir = root;
126
+ for (;;) {
127
+ if (check(dir)) return true;
128
+ const parent = dirname(dir);
129
+ if (parent === dir) return false;
130
+ dir = parent;
131
+ }
132
+ }
133
+ function hasConfig(root, names) {
134
+ return walkUp(root, (dir) => names.some((name) => existsSync(join(dir, name))));
135
+ }
136
+ function hasPackageJsonKey(root, key) {
137
+ return walkUp(root, (dir) => {
138
+ const pkgPath = join(dir, "package.json");
139
+ if (!existsSync(pkgPath)) return false;
140
+ try {
141
+ return key in JSON.parse(readFileSync(pkgPath, "utf8"));
142
+ } catch {
143
+ return false;
144
+ }
145
+ });
146
+ }
147
+ function findBin(root, bin) {
148
+ let found = null;
149
+ walkUp(root, (dir) => {
150
+ const candidate = join(dir, "node_modules", ".bin", bin);
151
+ if (existsSync(candidate)) {
152
+ found = candidate;
153
+ return true;
154
+ }
155
+ return false;
156
+ });
157
+ return found;
158
+ }
159
+ /**
160
+ * Detect the project's own formatter/linter fixers so generated code matches the
161
+ * project's conventions (quotes, semicolons, indentation, import order, …).
162
+ * Biome is all-in-one; otherwise eslint --fix (lint) then prettier/dprint (format).
163
+ */
164
+ function detectFixers(root) {
165
+ const biome = findBin(root, "biome");
166
+ if (biome && hasConfig(root, CONFIG_FILES.biome)) return [{
167
+ tool: "biome",
168
+ bin: biome,
169
+ args: (files) => [
170
+ "check",
171
+ "--write",
172
+ "--files-ignore-unknown=true",
173
+ ...files
174
+ ]
175
+ }];
176
+ const fixers = [];
177
+ const eslint = findBin(root, "eslint");
178
+ if (eslint && (hasConfig(root, CONFIG_FILES.eslint) || hasPackageJsonKey(root, "eslintConfig"))) fixers.push({
179
+ tool: "eslint",
180
+ bin: eslint,
181
+ args: (files) => [
182
+ "--fix",
183
+ "--no-error-on-unmatched-pattern",
184
+ ...files
185
+ ]
186
+ });
187
+ const prettier = findBin(root, "prettier");
188
+ if (prettier && (hasConfig(root, CONFIG_FILES.prettier) || hasPackageJsonKey(root, "prettier"))) fixers.push({
189
+ tool: "prettier",
190
+ bin: prettier,
191
+ args: (files) => [
192
+ "--write",
193
+ "--ignore-unknown",
194
+ ...files
195
+ ]
196
+ });
197
+ else {
198
+ const dprint = findBin(root, "dprint");
199
+ if (dprint && hasConfig(root, CONFIG_FILES.dprint)) fixers.push({
200
+ tool: "dprint",
201
+ bin: dprint,
202
+ args: (files) => ["fmt", ...files]
203
+ });
204
+ }
205
+ return fixers;
206
+ }
207
+ function chunk(items, size) {
208
+ const chunks = [];
209
+ for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
210
+ return chunks;
211
+ }
212
+ /**
213
+ * Run the project's detected fixers over the given files (best-effort — a fixer
214
+ * that exits non-zero, e.g. eslint with remaining lint errors, still counts as run).
215
+ */
216
+ async function formatFiles(root, files) {
217
+ const tools = [];
218
+ const errors = [];
219
+ if (files.length === 0) return {
220
+ tools,
221
+ errors
222
+ };
223
+ for (const fixer of detectFixers(root)) {
224
+ tools.push(fixer.tool);
225
+ for (const batch of chunk(files, CHUNK_SIZE)) try {
226
+ await execFileAsync(fixer.bin, fixer.args(batch), {
227
+ cwd: root,
228
+ maxBuffer: 32 * 1024 * 1024,
229
+ timeout: 18e4
230
+ });
231
+ } catch (error) {
232
+ const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
233
+ errors.push(`${fixer.tool}: ${message}`);
234
+ }
235
+ }
236
+ return {
237
+ tools,
238
+ errors
239
+ };
240
+ }
241
+ //#endregion
242
+ //#region src/runner.ts
243
+ const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"];
244
+ const DEFAULT_EXCLUDE = [
245
+ "**/node_modules/**",
246
+ "**/dist/**",
247
+ "**/*.d.ts"
248
+ ];
249
+ const RESOLVE_EXTENSIONS = [
250
+ "",
251
+ ".ts",
252
+ ".tsx",
253
+ ".js",
254
+ ".jsx",
255
+ ".mts",
256
+ ".cts",
257
+ ".mjs",
258
+ ".cjs"
259
+ ];
260
+ const RESOLVE_INDEX = [
261
+ "/index.ts",
262
+ "/index.tsx",
263
+ "/index.js",
264
+ "/index.jsx"
265
+ ];
266
+ /** Best-effort resolution of a relative import specifier to an absolute file path. */
267
+ function resolveImport(fromFile, specifier) {
268
+ if (!specifier.startsWith(".")) return null;
269
+ const base = resolve(dirname(fromFile), specifier);
270
+ for (const ext of RESOLVE_EXTENSIONS) {
271
+ const candidate = base + ext;
272
+ if (candidate && existsSync(candidate) && !candidate.endsWith("/")) return candidate;
273
+ }
274
+ for (const index of RESOLVE_INDEX) {
275
+ const candidate = base + index;
276
+ if (existsSync(candidate)) return candidate;
277
+ }
278
+ return null;
279
+ }
280
+ /**
281
+ * Run a single plugin over a project: discover files, parse each once, then
282
+ * drive the analyze → transform → finalize lifecycle. Dry-run by default;
283
+ * pass `write: true` to persist changes and emitted files.
284
+ */
285
+ async function run(plugin, runOptions) {
286
+ const root = resolve(runOptions.root);
287
+ const include = runOptions.include ?? plugin.include ?? DEFAULT_INCLUDE;
288
+ const exclude = [...DEFAULT_EXCLUDE, ...runOptions.exclude ?? plugin.exclude ?? []];
289
+ const options = runOptions.options ?? {};
290
+ const files = await globby(include, {
291
+ cwd: root,
292
+ absolute: true,
293
+ ignore: exclude,
294
+ gitignore: true,
295
+ onlyFiles: true
296
+ });
297
+ const diagnostics = [];
298
+ const emitted = [];
299
+ const project = {
300
+ root,
301
+ state: plugin.createState ? plugin.createState() : {},
302
+ resolve: resolveImport
303
+ };
304
+ const loaded = [];
305
+ for (const path of files) {
306
+ const source = await readFile(path, "utf8");
307
+ const parsed = parseFile(path, source);
308
+ const magic = new MagicString(source);
309
+ const ctx = {
310
+ path,
311
+ source,
312
+ program: parsed.program,
313
+ comments: parsed.comments,
314
+ magic,
315
+ report: (d) => diagnostics.push({
316
+ ...d,
317
+ file: path
318
+ }),
319
+ emit: (f) => emitted.push(f)
320
+ };
321
+ loaded.push({
322
+ path,
323
+ source,
324
+ ctx
325
+ });
326
+ }
327
+ if (plugin.analyze) for (const { ctx } of loaded) await plugin.analyze(ctx, project, options);
328
+ for (const { ctx } of loaded) await plugin.transform(ctx, project, options);
329
+ if (plugin.finalize) {
330
+ const extra = plugin.finalize(project, options);
331
+ if (extra) emitted.push(...extra);
332
+ }
333
+ const changed = [];
334
+ for (const { path, source, ctx } of loaded) if (ctx.magic.hasChanged()) changed.push({
335
+ path,
336
+ before: source,
337
+ after: ctx.magic.toString()
338
+ });
339
+ let format;
340
+ if (runOptions.write) {
341
+ const written = [];
342
+ for (const change of changed) {
343
+ await writeFile(change.path, change.after, "utf8");
344
+ written.push(change.path);
345
+ }
346
+ for (const file of emitted) {
347
+ const abs = isAbsolute(file.path) ? file.path : resolve(root, file.path);
348
+ await mkdir(dirname(abs), { recursive: true });
349
+ await writeFile(abs, file.contents, "utf8");
350
+ written.push(abs);
351
+ }
352
+ if (runOptions.format !== false && written.length > 0) format = await formatFiles(root, written);
353
+ }
354
+ return {
355
+ changed,
356
+ emitted,
357
+ diagnostics,
358
+ format
359
+ };
360
+ }
361
+ //#endregion
362
+ //#region src/diff.ts
363
+ function colorize(patch) {
364
+ return patch.split("\n").map((line) => {
365
+ if (line.startsWith("+") && !line.startsWith("+++")) return pc.green(line);
366
+ if (line.startsWith("-") && !line.startsWith("---")) return pc.red(line);
367
+ if (line.startsWith("@@")) return pc.cyan(line);
368
+ return line;
369
+ }).join("\n");
370
+ }
371
+ /** A colored unified diff for a modified file. */
372
+ function renderDiff(change) {
373
+ return colorize(createTwoFilesPatch(change.path, change.path, change.before, change.after, void 0, void 0, { context: 3 }));
374
+ }
375
+ /** A colored unified diff for a newly created file. */
376
+ function renderEmitted(file) {
377
+ return colorize(createTwoFilesPatch("/dev/null", file.path, "", file.contents, void 0, void 0, { context: 3 }));
378
+ }
379
+ //#endregion
380
+ export { definePlugin, detectFixers, formatFiles, langFromPath, parseFile, renderDiff, renderEmitted, resolveImport, run, walk };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@omnimod/core",
3
+ "version": "0.1.0",
4
+ "description": "Core engine for omnimod, a modular codemod tool built on oxc-parser + magic-string.",
5
+ "keywords": [
6
+ "ast",
7
+ "codemod",
8
+ "magic-string",
9
+ "oxc",
10
+ "refactoring",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://salnika.github.io/omnimod/",
14
+ "bugs": "https://github.com/salnika/omnimod/issues",
15
+ "license": "MIT",
16
+ "author": "salnika",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/salnika/omnimod.git",
20
+ "directory": "packages/core"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": "./dist/index.mjs",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "diff": "^9.0.0",
35
+ "globby": "^16.2.0",
36
+ "magic-string": "^0.30.21",
37
+ "oxc-parser": "^0.138.0",
38
+ "picocolors": "^1.1.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^25.6.2",
42
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
43
+ "typescript": "^6.0.3",
44
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
45
+ "vite-plus": "0.2.2"
46
+ },
47
+ "scripts": {
48
+ "build": "vp pack",
49
+ "dev": "vp pack --watch",
50
+ "test": "vp test",
51
+ "check": "vp check"
52
+ }
53
+ }