@bpmn-io/codemods 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/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # @bpmn-io/codemods
2
+
3
+ A collection of codemods for the bpmn.io ecosystem, exposed via CLI.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @bpmn-io/codemods <mod> [options]
9
+
10
+ # e.g.
11
+ npx @bpmn-io/codemods esm --dry-run src
12
+ ```
13
+
14
+ Run a mod with `--help` for its options.
15
+
16
+ ## Available mods
17
+
18
+ | Mod | Purpose |
19
+ | --- | --- |
20
+ | [`esm`](./esm) | Append explicit extensions to imports so they resolve under native ES module resolution (e.g. migrating to "ESM only" packages such as diagram-js). |
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ npm install
26
+ npm test
27
+ ```
28
+
29
+ `npm test` runs the tests of every mod (`*/test/**/*.spec.js`).
30
+
31
+ ## Adding a mod
32
+
33
+ > [!NOTE]
34
+ > Each mod lives in its own sub-folder with its own library API and tests and is run through a single CLI.
35
+
36
+ Create a new sub-folder mirroring [`esm`](./esm):
37
+
38
+ ```
39
+ <mod>/
40
+ lib/cli.js # exports `run(argv)` — the CLI handler
41
+ lib/ # library implementation
42
+ test/ # *.spec.js + fixtures
43
+ README.md
44
+ ```
45
+
46
+ Then register it in [bin/codemods.js](./bin/codemods.js):
47
+
48
+ ```js
49
+ import { run as myMod } from '../<mod>/lib/cli.js';
50
+
51
+ const MODS = {
52
+ esm,
53
+ myMod
54
+ };
55
+ ```
56
+
57
+ Add a row to the table above and list `<mod>/lib` in the `files` field of the
58
+ root [package.json](./package.json). Shared dev dependencies (`@babel/parser`,
59
+ `mocha`, `chai`) live at the root.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { run as esm } from '../esm/lib/cli.js';
3
+
4
+ /**
5
+ * Registry of available codemods, keyed by their CLI name.
6
+ *
7
+ * Each handler receives the arguments that follow the mod name.
8
+ *
9
+ * @type {Record<string, (argv: string[]) => void>}
10
+ */
11
+ const MODS = {
12
+ esm
13
+ };
14
+
15
+ const HELP = `Usage: npx @bpmn-io/codemods <mod> [options]
16
+
17
+ A collection of codemods for the bpmn.io ecosystem.
18
+
19
+ Mods:
20
+ ${Object.keys(MODS).map((name) => ` ${name}`).join('\n')}
21
+
22
+ Run a mod with --help for its options, e.g.:
23
+ npx @bpmn-io/codemods esm --help
24
+ `;
25
+
26
+ main(process.argv.slice(2));
27
+
28
+ function main(argv) {
29
+ const [ mod, ...rest ] = argv;
30
+
31
+ if (!mod || mod === '--help' || mod === '-h') {
32
+ process.stdout.write(HELP);
33
+ process.exitCode = mod ? 0 : 1;
34
+ return;
35
+ }
36
+
37
+ const handler = MODS[mod];
38
+
39
+ if (!handler) {
40
+ process.stderr.write(`Unknown mod: ${mod}\n\n${HELP}`);
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+
45
+ handler(rest);
46
+ }
package/esm/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # esm codemod
2
+
3
+ Appends explicit extensions to `import` / `export ... from` sources and dynamic
4
+ `import('...')` calls so they resolve under **native ES module resolution**.
5
+
6
+ This is useful when migrating to a package that ships "ESM only". For example,
7
+ [diagram-js](https://github.com/bpmn-io/diagram-js) used to allow extension-less
8
+ deep imports of its utilities; the ESM-only releases require an explicit
9
+ extension:
10
+
11
+ ```diff
12
+ - import { getParents } from 'diagram-js/lib/util/Elements';
13
+ + import { getParents } from 'diagram-js/lib/util/Elements.js';
14
+ ```
15
+
16
+ The mod is not tied to diagram-js — it works for any relative or package import
17
+ whose target file exists once an extension is appended.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ npx @bpmn-io/codemods esm <dir|file>
23
+
24
+ # preview without writing
25
+ npx @bpmn-io/codemods esm --dry-run src
26
+ ```
27
+
28
+ Run it from your project root so that `node_modules` (the installed,
29
+ already-migrated package) is reachable for resolution.
30
+
31
+ The command exits with code `1` if any import could not be resolved, so it can
32
+ be used as a CI guard.
33
+
34
+ ## What it does
35
+
36
+ For every `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts` and `.tsx` file below the target
37
+ (ignoring `node_modules` and `.git`):
38
+
39
+ 1. Each static `import` / `export ... from` source, and every dynamic
40
+ `import('...')` call with a string-literal argument, is checked against ES
41
+ module resolution.
42
+ 2. If the specifier does not resolve as written but does once an extension is
43
+ appended, the extension is added.
44
+ - Relative and bare (`node_modules`) specifiers are both handled.
45
+ - TypeScript / JSX sources are written with a `.js` specifier (NodeNext
46
+ convention), even though the file on disk is `.ts`/`.tsx`.
47
+ 3. Specifiers that already carry an extension, and bare package roots
48
+ (e.g. `diagram-js`, `react`), are left untouched.
49
+ 4. Anything that still cannot be resolved (e.g. a directory import that would
50
+ need `/index.js`, or a genuinely missing file) is **reported** for manual
51
+ review — never silently changed.
52
+
53
+ Only the source string is edited; surrounding formatting is preserved exactly.
54
+
55
+ ## Programmatic API
56
+
57
+ ```js
58
+ import { migrate } from '@bpmn-io/codemods/esm/lib/migrate.js';
59
+
60
+ const report = migrate('src', { dryRun: true });
61
+ ```
62
+
63
+ ### Out of scope
64
+
65
+ By design, the mod does **not**:
66
+
67
+ - rewrite `require()` calls,
68
+ - rewrite dynamic imports with a non-literal argument (`import(name)`),
69
+ - add `/index.js` for directory imports,
70
+ - guess at unresolvable imports — these are reported instead.
package/esm/lib/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ import path from 'node:path';
2
+
3
+ import { migrate } from './migrate.js';
4
+
5
+ const HELP = `Usage: npx @bpmn-io/codemods esm [options] <dir|file>
6
+
7
+ Append explicit extensions to import/export sources so they resolve under
8
+ native ES module resolution (e.g. when migrating to a package that ships
9
+ "ESM only", such as diagram-js).
10
+
11
+ Options:
12
+ -d, --dry-run Report changes without writing files
13
+ -h, --help Show this help
14
+
15
+ Exit code is 1 when any import could not be resolved.
16
+ `;
17
+
18
+ /**
19
+ * Run the `esm` codemod CLI.
20
+ *
21
+ * @param {string[]} argv - arguments after the mod name
22
+ */
23
+ export function run(argv) {
24
+ const dryRun = argv.includes('--dry-run') || argv.includes('-d');
25
+ const help = argv.includes('--help') || argv.includes('-h');
26
+
27
+ if (help) {
28
+ process.stdout.write(HELP);
29
+ return;
30
+ }
31
+
32
+ const target = argv.find((arg) => !arg.startsWith('-')) || process.cwd();
33
+
34
+ const report = migrate(target, { dryRun });
35
+
36
+ print(report, dryRun);
37
+
38
+ process.exitCode = report.unresolved.length || report.errors.length ? 1 : 0;
39
+ }
40
+
41
+ function print(report, dryRun) {
42
+ const rel = (file) => path.relative(process.cwd(), file) || file;
43
+
44
+ if (report.rewrites.length) {
45
+ process.stdout.write(`\n${dryRun ? 'Would rewrite' : 'Rewrote'} imports:\n`);
46
+
47
+ for (const { file, from, to, line } of report.rewrites) {
48
+ process.stdout.write(` ${rel(file)}:${line} ${from} -> ${to}\n`);
49
+ }
50
+ }
51
+
52
+ if (report.unresolved.length) {
53
+ process.stdout.write('\nCould not resolve (please check manually):\n');
54
+
55
+ for (const { file, specifier, line } of report.unresolved) {
56
+ process.stdout.write(` ${rel(file)}:${line} ${specifier}\n`);
57
+ }
58
+ }
59
+
60
+ if (report.errors.length) {
61
+ process.stdout.write('\nFailed to parse:\n');
62
+
63
+ for (const { file, message } of report.errors) {
64
+ process.stdout.write(` ${rel(file)} ${message}\n`);
65
+ }
66
+ }
67
+
68
+ process.stdout.write(
69
+ `\n${report.filesScanned} file(s) scanned, ` +
70
+ `${report.filesChanged} changed, ` +
71
+ `${report.importsRewritten} import(s) rewritten, ` +
72
+ `${report.unresolved.length} unresolved.\n`
73
+ );
74
+ }
@@ -0,0 +1,92 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { transform } from './transform.js';
5
+
6
+ const SOURCE_EXTENSIONS = new Set([ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx' ]);
7
+ const IGNORED_DIRECTORIES = new Set([ 'node_modules', '.git' ]);
8
+
9
+ /**
10
+ * @typedef {Object} Report
11
+ * @property {number} filesScanned
12
+ * @property {number} filesChanged
13
+ * @property {number} importsRewritten
14
+ * @property {Array<{ file: string, from: string, to: string, line: number }>} rewrites
15
+ * @property {Array<{ file: string, specifier: string, line: number }>} unresolved
16
+ * @property {Array<{ file: string, message: string }>} errors
17
+ */
18
+
19
+ /**
20
+ * Recursively migrate all JS/TS files below `target` (or a single file),
21
+ * appending explicit extensions to imports that would otherwise not resolve
22
+ * under native ES module resolution.
23
+ *
24
+ * @param {string} target - directory or file to migrate
25
+ * @param {{ dryRun?: boolean }} [options]
26
+ *
27
+ * @return {Report}
28
+ */
29
+ export function migrate(target, options = {}) {
30
+ const { dryRun = false } = options;
31
+
32
+ const report = {
33
+ filesScanned: 0,
34
+ filesChanged: 0,
35
+ importsRewritten: 0,
36
+ rewrites: [],
37
+ unresolved: [],
38
+ errors: []
39
+ };
40
+
41
+ for (const file of collectFiles(path.resolve(target))) {
42
+ report.filesScanned++;
43
+
44
+ const code = fs.readFileSync(file, 'utf8');
45
+ const result = transform(code, file);
46
+
47
+ if (result.error) {
48
+ report.errors.push({ file, message: result.error.message });
49
+ continue;
50
+ }
51
+
52
+ for (const u of result.unresolved) {
53
+ report.unresolved.push({ file, ...u });
54
+ }
55
+
56
+ if (result.changed) {
57
+ report.filesChanged++;
58
+ report.importsRewritten += result.changes.length;
59
+
60
+ for (const c of result.changes) {
61
+ report.rewrites.push({ file, ...c });
62
+ }
63
+
64
+ if (!dryRun) {
65
+ fs.writeFileSync(file, result.code);
66
+ }
67
+ }
68
+ }
69
+
70
+ return report;
71
+ }
72
+
73
+ function* collectFiles(target) {
74
+ const stat = fs.statSync(target);
75
+
76
+ if (stat.isFile()) {
77
+ yield target;
78
+ return;
79
+ }
80
+
81
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
82
+ const full = path.join(target, entry.name);
83
+
84
+ if (entry.isDirectory()) {
85
+ if (!IGNORED_DIRECTORIES.has(entry.name)) {
86
+ yield* collectFiles(full);
87
+ }
88
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
89
+ yield full;
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Extensions that, when already present on a specifier, mean the import is
6
+ * explicit and must be left untouched (module sources as well as assets that
7
+ * are handled by bundlers, never by ES module resolution).
8
+ */
9
+ const KNOWN_EXTENSIONS = new Set([
10
+ '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.json', '.node',
11
+ '.css', '.scss', '.sass', '.less', '.styl',
12
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.wasm',
13
+ '.html', '.xml', '.md', '.txt', '.vue', '.bpmn', '.dmn', '.form'
14
+ ]);
15
+
16
+ /**
17
+ * Extensions tried for relative imports, in priority order, mapped to the
18
+ * extension that should be written into the specifier.
19
+ *
20
+ * TypeScript / JSX source files are resolved with `.js` specifiers (the
21
+ * NodeNext / "ESM only" convention), even though the file on disk is `.ts`.
22
+ */
23
+ const RELATIVE_CANDIDATES = [
24
+ { file: '.js', write: '.js' },
25
+ { file: '.jsx', write: '.js' },
26
+ { file: '.ts', write: '.js' },
27
+ { file: '.tsx', write: '.js' },
28
+ { file: '.mjs', write: '.mjs' },
29
+ { file: '.cjs', write: '.cjs' },
30
+ { file: '.json', write: '.json' }
31
+ ];
32
+
33
+ /**
34
+ * Extensions tried for bare (node_modules) imports. Published packages ship
35
+ * compiled files, so we only look for runtime extensions.
36
+ */
37
+ const BARE_CANDIDATES = [
38
+ { file: '.js', write: '.js' },
39
+ { file: '.mjs', write: '.mjs' },
40
+ { file: '.cjs', write: '.cjs' },
41
+ { file: '.json', write: '.json' }
42
+ ];
43
+
44
+ /**
45
+ * Decide how a single import specifier should be migrated for native ES
46
+ * module resolution.
47
+ *
48
+ * @param {string} specifier - the raw import source, e.g. `diagram-js/lib/util/Elements`
49
+ * @param {string} fromFile - absolute path of the importing file
50
+ *
51
+ * @return {{ status: ('skip'|'rewrite'|'unresolved'), specifier: string }}
52
+ * `skip` if already resolvable, `rewrite` with the new specifier if an
53
+ * extension was appended, `unresolved` if no `.js`-style extension resolves.
54
+ */
55
+ export function resolveImport(specifier, fromFile) {
56
+ const bare = isBare(specifier);
57
+
58
+ // bare package roots (e.g. 'react', 'diagram-js') resolve via main/exports
59
+ if (bare && isBareRoot(specifier)) {
60
+ return { status: 'skip', specifier };
61
+ }
62
+
63
+ // already carries an explicit extension
64
+ if (KNOWN_EXTENSIONS.has(specifierExtension(specifier))) {
65
+ return { status: 'skip', specifier };
66
+ }
67
+
68
+ const rewritten = bare
69
+ ? resolveBare(specifier, fromFile)
70
+ : resolveRelative(specifier, fromFile);
71
+
72
+ if (rewritten) {
73
+ return { status: 'rewrite', specifier: rewritten };
74
+ }
75
+
76
+ return { status: 'unresolved', specifier };
77
+ }
78
+
79
+ function resolveRelative(specifier, fromFile) {
80
+ const base = path.resolve(path.dirname(fromFile), specifier);
81
+
82
+ return resolveCandidates(base, specifier, RELATIVE_CANDIDATES);
83
+ }
84
+
85
+ function resolveBare(specifier, fromFile) {
86
+ const { pkg, subpath } = parseBare(specifier);
87
+
88
+ const pkgDir = findPackageDir(pkg, path.dirname(fromFile));
89
+
90
+ if (!pkgDir) {
91
+ return null;
92
+ }
93
+
94
+ return resolveCandidates(path.join(pkgDir, subpath), specifier, BARE_CANDIDATES);
95
+ }
96
+
97
+ function resolveCandidates(basePath, specifier, candidates) {
98
+ for (const { file, write } of candidates) {
99
+ if (isFile(basePath + file)) {
100
+ return specifier + write;
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Locate a package directory by walking up the `node_modules` chain, starting
109
+ * from the importing file's directory.
110
+ */
111
+ function findPackageDir(pkg, fromDir) {
112
+ let dir = fromDir;
113
+
114
+ for (;;) {
115
+ const candidate = path.join(dir, 'node_modules', pkg);
116
+
117
+ if (isDirectory(candidate)) {
118
+ return candidate;
119
+ }
120
+
121
+ const parent = path.dirname(dir);
122
+
123
+ if (parent === dir) {
124
+ return null;
125
+ }
126
+
127
+ dir = parent;
128
+ }
129
+ }
130
+
131
+ function parseBare(specifier) {
132
+ if (specifier.startsWith('@')) {
133
+ const parts = specifier.split('/');
134
+
135
+ return {
136
+ pkg: parts.slice(0, 2).join('/'),
137
+ subpath: parts.slice(2).join('/')
138
+ };
139
+ }
140
+
141
+ const idx = specifier.indexOf('/');
142
+
143
+ return idx === -1
144
+ ? { pkg: specifier, subpath: '' }
145
+ : { pkg: specifier.slice(0, idx), subpath: specifier.slice(idx + 1) };
146
+ }
147
+
148
+ function isBare(specifier) {
149
+ return !specifier.startsWith('.') && !specifier.startsWith('/');
150
+ }
151
+
152
+ function isBareRoot(specifier) {
153
+ return parseBare(specifier).subpath === '';
154
+ }
155
+
156
+ function specifierExtension(specifier) {
157
+ const segment = specifier.split('/').pop();
158
+ const dot = segment.lastIndexOf('.');
159
+
160
+ return dot <= 0 ? '' : segment.slice(dot).toLowerCase();
161
+ }
162
+
163
+ function isFile(p) {
164
+ try {
165
+ return fs.statSync(p).isFile();
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function isDirectory(p) {
172
+ try {
173
+ return fs.statSync(p).isDirectory();
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
@@ -0,0 +1,151 @@
1
+ import path from 'node:path';
2
+
3
+ import { parse } from '@babel/parser';
4
+
5
+ import { resolveImport } from './resolve.js';
6
+
7
+ /**
8
+ * Rewrite the import/export sources of a single file so that they resolve
9
+ * under native ES module resolution.
10
+ *
11
+ * Only the source string literals are touched; the rest of the file is left
12
+ * byte-for-byte unchanged.
13
+ *
14
+ * @param {string} code - the file contents
15
+ * @param {string} filename - absolute path of the file (used for resolution)
16
+ *
17
+ * @return {{
18
+ * code: string,
19
+ * changed: boolean,
20
+ * changes: Array<{ from: string, to: string, line: number }>,
21
+ * unresolved: Array<{ specifier: string, line: number }>,
22
+ * error: (Error|null)
23
+ * }}
24
+ */
25
+ export function transform(code, filename) {
26
+ let ast;
27
+
28
+ try {
29
+ ast = parse(code, {
30
+ sourceType: 'module',
31
+ allowReturnOutsideFunction: true,
32
+ plugins: pluginsFor(filename)
33
+ });
34
+ } catch (error) {
35
+ return { code, changed: false, changes: [], unresolved: [], error };
36
+ }
37
+
38
+ const edits = [];
39
+ const changes = [];
40
+ const unresolved = [];
41
+
42
+ for (const source of importSources(ast)) {
43
+ const result = resolveImport(source.value, filename);
44
+ const line = source.loc.start.line;
45
+
46
+ if (result.status === 'rewrite') {
47
+ const quote = code[source.start];
48
+
49
+ edits.push({
50
+ start: source.start,
51
+ end: source.end,
52
+ text: quote + result.specifier + quote
53
+ });
54
+
55
+ changes.push({ from: source.value, to: result.specifier, line });
56
+ } else if (result.status === 'unresolved') {
57
+ unresolved.push({ specifier: source.value, line });
58
+ }
59
+ }
60
+
61
+ return {
62
+ code: applyEdits(code, edits),
63
+ changed: edits.length > 0,
64
+ changes,
65
+ unresolved,
66
+ error: null
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Collect the source string literals of all static `import`/`export ... from`
72
+ * declarations as well as dynamic `import('...')` calls with a string argument.
73
+ *
74
+ * Dynamic imports with a non-literal argument (template, variable) cannot be
75
+ * resolved statically and are therefore ignored.
76
+ */
77
+ function importSources(ast) {
78
+ const sources = [];
79
+
80
+ for (const node of walk(ast.program)) {
81
+ if (
82
+ (node.type === 'ImportDeclaration' ||
83
+ node.type === 'ExportNamedDeclaration' ||
84
+ node.type === 'ExportAllDeclaration') &&
85
+ node.source
86
+ ) {
87
+ sources.push(node.source);
88
+ } else if (node.type === 'CallExpression' && node.callee.type === 'Import') {
89
+ const [ arg ] = node.arguments;
90
+
91
+ if (arg && arg.type === 'StringLiteral') {
92
+ sources.push(arg);
93
+ }
94
+ }
95
+ }
96
+
97
+ return sources;
98
+ }
99
+
100
+ /**
101
+ * Depth-first traversal over every AST node, dependency-free.
102
+ */
103
+ function* walk(node) {
104
+ yield node;
105
+
106
+ for (const key in node) {
107
+ const value = node[key];
108
+
109
+ if (Array.isArray(value)) {
110
+ for (const child of value) {
111
+ if (isNode(child)) {
112
+ yield* walk(child);
113
+ }
114
+ }
115
+ } else if (isNode(value)) {
116
+ yield* walk(value);
117
+ }
118
+ }
119
+ }
120
+
121
+ function isNode(value) {
122
+ return value !== null && typeof value === 'object' && typeof value.type === 'string';
123
+ }
124
+
125
+ /**
126
+ * Apply replacements to the source string. Edits are applied back-to-front so
127
+ * that earlier offsets stay valid.
128
+ */
129
+ function applyEdits(code, edits) {
130
+ let result = code;
131
+
132
+ for (const { start, end, text } of edits.sort((a, b) => b.start - a.start)) {
133
+ result = result.slice(0, start) + text + result.slice(end);
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ function pluginsFor(filename) {
140
+ const ext = path.extname(filename).toLowerCase();
141
+
142
+ if (ext === '.ts') {
143
+ return [ 'typescript', 'decorators-legacy' ];
144
+ }
145
+
146
+ if (ext === '.tsx') {
147
+ return [ 'typescript', 'jsx', 'decorators-legacy' ];
148
+ }
149
+
150
+ return [ 'jsx', 'decorators-legacy' ];
151
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@bpmn-io/codemods",
3
+ "version": "0.1.0",
4
+ "description": "A collection of codemods for the bpmn.io ecosystem.",
5
+ "type": "module",
6
+ "bin": {
7
+ "codemods": "bin/codemods.js"
8
+ },
9
+ "scripts": {
10
+ "all": "npm run test",
11
+ "test": "mocha"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "esm/lib",
16
+ "esm/README.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@babel/parser": "^7.24.0"
27
+ },
28
+ "devDependencies": {
29
+ "chai": "^5.1.0",
30
+ "mocha": "^10.4.0"
31
+ }
32
+ }