@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 +21 -0
- package/README.md +39 -0
- package/dist/index.d.mts +205 -0
- package/dist/index.mjs +380 -0
- package/package.json +53 -0
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
|
+
[](https://www.npmjs.com/package/@omnimod/core)
|
|
4
|
+
[](https://github.com/Salnika/omnimod/actions/workflows/ci.yml)
|
|
5
|
+
[](../../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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|