di-bag-codemod 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) 2022 Danylo Fedorov
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,114 @@
1
+ # di-bag-codemod
2
+
3
+ Moves code written for [DI Bag](https://github.com/dany-fedorov/di-bag) 0.4 to
4
+ the 0.5 API. It uses the TypeScript checker to authenticate calls, properties,
5
+ contextually typed object keys, and exported type references as DI Bag uses.
6
+ Argument values are rewritten only within those authenticated uses. Runtime
7
+ `DI_BAG_*` strings and module specifiers follow explicit exact or suffix rules
8
+ in the rename map; they do not depend on declaration resolution. Ordinary uses
9
+ such as `text.replace(...)`, `Promise.all(...)`, and your own `register` method
10
+ are left alone.
11
+
12
+ Run it **before** you upgrade, while `di-bag` 0.4 is still installed: every
13
+ type-based decision reads the old declarations. Runs are dry by default;
14
+ `--write` applies the changes, and `--report <file>` records every item left
15
+ for a person. Scoped remains the default lifetime in 0.5, so no blanket
16
+ lifetime pinning is needed. Proven child-replacement lifetime adjustments are
17
+ automatic; ambiguous cases stay unchanged as manual items. See the
18
+ [0.5 migration guide](https://github.com/dany-fedorov/di-bag/blob/main/docs/guides/migrating-to-0.5.md)
19
+ for the complete upgrade sequence.
20
+
21
+ ## Commands
22
+
23
+ ```sh
24
+ npx di-bag-codemod # dry run over ./tsconfig.json
25
+ npx di-bag-codemod --write # apply
26
+ npx di-bag-codemod --project tsconfig.app.json --write --report codemod-report.json
27
+ npx di-bag-codemod src/app.ts src/worker.ts # explicit files instead of a project
28
+ ```
29
+
30
+ Pass either `--project <tsconfig>` or positional source files. Passing both is
31
+ a usage error; use repeatable `--extra-files` to add files excluded by a
32
+ tsconfig. Without either input form, `./tsconfig.json` is used. A run prints one
33
+ line per changed file, one entry per manual item, and a summary:
34
+
35
+ ```text
36
+ would rewrite src/app.ts: 3 rewrites
37
+ manual src/app.ts:41:52 startupOrder is not a literal; use maxConcurrentServiceKeys: omit it for 'parallel', 1 for 'sequential', or the number
38
+ startupOrder: order
39
+ 1 files, 3 rewrites, 1 manual items (dry run; pass --write to apply) (TypeScript 6.0.3, project)
40
+ ```
41
+
42
+ | Option | Meaning |
43
+ | --- | --- |
44
+ | `--write` | Write the rewritten files. Without it nothing changes on disk. |
45
+ | `--report <file>` | Write the changed files and complete manual items as JSON. |
46
+ | `--extra-files <glob>` | Add files the tsconfig excludes. Repeatable. `*`, `**`, and `?` are supported. |
47
+ | `--library-root <dir>` | Treat declarations under this directory as DI Bag. Repeatable. Without this option, installed `node_modules/di-bag` declarations are recognized automatically; when supplied, only the listed roots count. |
48
+ | `--map <file>` | Use a rename map other than the one in this package. |
49
+ | `--help`, `-h` | Print command usage. |
50
+
51
+ Exit codes: `0` means the run finished, with or without manual items; `2` means
52
+ a usage, tsconfig, or rename-map error.
53
+
54
+ ## Manual items
55
+
56
+ The codemod never guesses. What it cannot decide from the original program it
57
+ leaves unchanged and reports with file, line, column, reason, and source text:
58
+
59
+ - options that are not an object literal, or that contain a spread;
60
+ - a string value that is not a literal;
61
+ - a method referenced or destructured without being called when its arguments
62
+ change shape;
63
+ - a call with a spread argument;
64
+ - a receiver of type `any`;
65
+ - a runtime code split into several replacements, and an old code inside a
66
+ regular expression, template, or comment;
67
+ - a re-export of a renamed name, which keeps its old public name.
68
+
69
+ Fix these by hand, then run the compiler.
70
+
71
+ Qualified members in `typeof` follow the same uncalled-reference rule: a compatible
72
+ plain rename changes the terminal member, while reshaped or ambiguous methods stay
73
+ unchanged and are reported for manual migration.
74
+
75
+ ## The rename map
76
+
77
+ `rename-map.json` describes the distance from 0.4.0 to the current API.
78
+ `rename-map.schema.json` documents every field.
79
+ Method and property targets must be bare ASCII identifiers. Type targets have
80
+ the same form and cannot be TypeScript keywords or primitive type names. Keys
81
+ emitted inside options bags may contain other characters and are quoted safely.
82
+
83
+ | Section | Rewrites |
84
+ | --- | --- |
85
+ | `methods` | a method name, its arguments into one options bag or an array, or a custom transform |
86
+ | `options` | a key of an object-literal argument, by argument position and path |
87
+ | `values` | a string at an argument path, or a string compared with or assigned to a library property |
88
+ | `properties` | a property of a library type: access, destructuring, and keys of contextually typed object literals |
89
+ | `types` | an exported class, interface, type alias, or error class in imports and references |
90
+ | `codes` | an exact `DI_BAG_*` runtime code in a string literal |
91
+ | `imports` | an exact module specifier or a suffix of a relative module specifier |
92
+
93
+ An owner is the original declaration that holds the member: `Builder`, `Bag`,
94
+ `DiBagApi`, or `StartupOptions`. A type written inline in a signature belongs
95
+ to that function, such as `fromFactory()`, or method, such as
96
+ `Bag.createScope()`. A custom method transform reads emitted method and existing
97
+ property names from the map. Its optional `transformNames` object supplies
98
+ role-based names for fields that had no declaration in 0.4.
99
+ For a same-name method that accepts both its old positional form and its new
100
+ options-bag form, `arguments.alreadyBag: true` makes the codemod ask the
101
+ TypeScript checker which form a one-argument call uses. It preserves a proven
102
+ bag, converts a proven old value, and reports an ambiguous value for manual
103
+ migration instead of guessing.
104
+
105
+ ## Limits
106
+
107
+ The tool rewrites TypeScript and TSX files in the selected program. It does not
108
+ read Markdown, generated source held in strings, or JavaScript without types.
109
+ It preserves formatting and does not run a formatter.
110
+
111
+ When the project provides a `typescript` package with compiler API version
112
+ 6.0.3 or later, the codemod uses that project compiler. If no project compiler
113
+ can be resolved, it is older, or it lacks the required JavaScript compiler API,
114
+ the codemod uses its bundled TypeScript and identifies the choice in the summary.
package/cli.mjs ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // tools/codemod/cli.mjs
3
+ import { existsSync, writeFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { loadTypeScript, runCodemod } from './lib/codemod.mjs';
6
+
7
+ const usage = 'usage: di-bag-codemod [--project tsconfig.json | file.ts ...] [--library-root dir]... [--extra-files glob]... [--map rename-map.json] [--write] [--report report.json]';
8
+ const args = process.argv.slice(2);
9
+ const files = [];
10
+ const extraFiles = [];
11
+ const libraryRoots = [];
12
+ let project, mapFile, report, write = false;
13
+ const value = index => {
14
+ if (args[index] === undefined || args[index].startsWith('-')) fail(`${args[index - 1]} needs a value`);
15
+ return args[index];
16
+ };
17
+ for (let index = 0; index < args.length; index++) {
18
+ const argument = args[index];
19
+ if (argument === '--project') project = value(++index);
20
+ else if (argument === '--library-root') libraryRoots.push(value(++index));
21
+ else if (argument === '--extra-files') extraFiles.push(value(++index));
22
+ else if (argument === '--map') mapFile = value(++index);
23
+ else if (argument === '--report') report = value(++index);
24
+ else if (argument === '--write') write = true;
25
+ else if (argument === '--help' || argument === '-h') { console.log(usage); process.exit(0); }
26
+ else if (argument.startsWith('-')) fail(`unknown option ${argument}`);
27
+ else files.push(argument);
28
+ }
29
+ if (project && files.length > 0) fail('--project cannot be used with positional files');
30
+ if (!project && files.length === 0) {
31
+ if (!existsSync('tsconfig.json')) fail('no tsconfig.json in the current directory; pass --project or files');
32
+ project = 'tsconfig.json';
33
+ }
34
+
35
+ function fail(message) {
36
+ console.error(`di-bag-codemod: ${message}\n${usage}`);
37
+ process.exit(2);
38
+ }
39
+
40
+ const root = process.cwd();
41
+ const compiler = loadTypeScript(project ? dirname(resolve(root, project)) : root);
42
+ let result;
43
+ try {
44
+ result = runCodemod({ typescript: compiler.ts, root, project, files, extraFiles, libraryRoots, write, ...(mapFile ? { mapFile: resolve(root, mapFile) } : {}) });
45
+ } catch (error) {
46
+ fail(error.message);
47
+ }
48
+ if (report) writeFileSync(report, JSON.stringify({ version: 1, written: write, files: result.files.map(({ file, rewrites }) => ({ file, rewrites })), manual: result.manual }, null, 2) + '\n');
49
+ for (const file of result.files) console.log(`${write ? 'rewrote' : 'would rewrite'} ${file.file}: ${file.rewrites} rewrites`);
50
+ for (const item of result.manual) console.log(`manual ${item.file}:${item.line}:${item.column} ${item.reason}${item.text ? `\n ${item.text}` : ''}`);
51
+ console.log(`${result.files.length} files, ${result.rewrites} rewrites, ${result.manual.length} manual items${write ? '' : ' (dry run; pass --write to apply)'} (TypeScript ${compiler.version}, ${compiler.source})`);
@@ -0,0 +1,77 @@
1
+ // tools/codemod/lib/codemod.mjs
2
+ import { writeFileSync } from 'node:fs';
3
+ import { dirname, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { expandGlob } from './glob.mjs';
6
+ import { createLibrary } from './library.mjs';
7
+ import { indexRenameMap, loadRenameMap, validateRenameMap } from './rename-map.mjs';
8
+ import { rewriteSourceFile } from './rewrite.mjs';
9
+ import { transforms } from './transforms/index.mjs';
10
+
11
+ export { loadTypeScript } from './load-typescript.mjs';
12
+ export { validateRenameMap } from './rename-map.mjs';
13
+ export { transforms };
14
+
15
+ /** The map shipped with this package: the distance from di-bag 0.4.0 to the current API. */
16
+ export const defaultMapFile = resolve(dirname(fileURLToPath(import.meta.url)), '../rename-map.json');
17
+
18
+ function loadProgram(ts, { project, files, root, extraFiles }) {
19
+ const extra = extraFiles.flatMap(pattern => expandGlob(pattern, root));
20
+ if (project) {
21
+ const config = ts.getParsedCommandLineOfConfigFile(resolve(root, project), {}, {
22
+ ...ts.sys, onUnRecoverableConfigFileDiagnostic: diagnostic => { throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); },
23
+ });
24
+ return ts.createProgram([...new Set([...config.fileNames, ...extra])], { ...config.options, noEmit: true });
25
+ }
26
+ return ts.createProgram([...new Set([...files.map(file => resolve(root, file)), ...extra])], {
27
+ strict: true, noEmit: true, skipLibCheck: true, types: [],
28
+ target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext,
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Rewrite every source file of a program that is neither a declaration file, nor under
34
+ * `node_modules`, nor part of the library itself.
35
+ * @param {{ typescript: typeof import('typescript'), root: string, project?: string, files?: string[], extraFiles?: string[],
36
+ * libraryRoots?: string[], map?: object, mapFile?: string, write?: boolean, only?: string[], program?: import('typescript').Program }} options
37
+ * @returns {{ files: { file: string, rewrites: number, text: string }[], manual: { file: string, line: number, column: number, reason: string, text: string }[], rewrites: number }}
38
+ */
39
+ export function runCodemod({ typescript: ts, root, project, files = [], extraFiles = [], libraryRoots = [], map, mapFile = defaultMapFile, write = false, only, program }) {
40
+ const renameMap = map ?? loadRenameMap(mapFile, Object.keys(transforms));
41
+ const problems = validateRenameMap(renameMap, Object.keys(transforms));
42
+ if (problems.length) throw new Error(`invalid rename map:\n${problems.join('\n')}`);
43
+ const index = indexRenameMap(renameMap);
44
+ const built = program ?? loadProgram(ts, { project, files, root, extraFiles });
45
+ const checker = built.getTypeChecker();
46
+ const library = createLibrary({ ts, checker, root, libraryRoots });
47
+ const selected = only === undefined ? undefined : new Set(only.map(file => resolve(root, file)));
48
+ const writableSourceFiles = new Set(built.getSourceFiles()
49
+ .map(sourceFile => resolve(sourceFile.fileName))
50
+ .filter(fileName => !fileName.includes('/node_modules/') && !library.isLibraryFile(fileName) && (selected === undefined || selected.has(fileName))));
51
+ const changed = [];
52
+ const manual = [];
53
+ let rewrites = 0;
54
+ for (const sourceFile of built.getSourceFiles()) {
55
+ const fileName = resolve(sourceFile.fileName);
56
+ if (sourceFile.isDeclarationFile || fileName.includes('/node_modules/') || library.isLibraryFile(fileName)) continue;
57
+ if (selected && !selected.has(fileName)) continue;
58
+ const fileLabel = relative(root, fileName).replaceAll('\\', '/');
59
+ let result;
60
+ try {
61
+ result = rewriteSourceFile({
62
+ ts, checker, program: built, sourceFile, library, index, transforms, writableSourceFiles,
63
+ manualItems: manual,
64
+ fileLabel,
65
+ });
66
+ } catch (error) {
67
+ manual.push({ file: fileLabel, line: 1, column: 1, reason: `this file was left untouched: ${error.message}`, text: '' });
68
+ continue;
69
+ }
70
+ if (result.text === sourceFile.text) continue;
71
+ changed.push({ file: fileLabel, rewrites: result.rewrites, text: result.text });
72
+ rewrites += result.rewrites;
73
+ if (write) writeFileSync(fileName, result.text);
74
+ }
75
+ manual.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column);
76
+ return { files: changed, manual, rewrites };
77
+ }
package/lib/glob.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // tools/codemod/lib/glob.mjs
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+
5
+ const WILDCARD = /[*?]/;
6
+
7
+ function toRegExp(pattern) {
8
+ let source = '';
9
+ for (let index = 0; index < pattern.length; index++) {
10
+ const character = pattern[index];
11
+ if (character === '*' && pattern[index + 1] === '*') {
12
+ // `**/` matches any number of directories, including none.
13
+ if (pattern[index + 2] === '/') { source += '(?:.*/)?'; index += 2; } else { source += '.*'; index += 1; }
14
+ } else if (character === '*') source += '[^/]*';
15
+ else if (character === '?') source += '[^/]';
16
+ else source += character.replace(/[.+^${}()|[\]\\]/g, '\\$&');
17
+ }
18
+ return new RegExp(`^${source}$`);
19
+ }
20
+
21
+ /**
22
+ * Expand one glob (`*`, `**`, `?`) relative to `root` into sorted absolute file paths.
23
+ * A pattern without wildcards names one file. `node_modules` directories are never entered.
24
+ * @param {string} pattern
25
+ * @param {string} root
26
+ * @returns {string[]}
27
+ */
28
+ export function expandGlob(pattern, root) {
29
+ const normalized = pattern.replaceAll('\\', '/');
30
+ const segments = normalized.split('/');
31
+ if (segments.includes('node_modules')) return [];
32
+ if (!WILDCARD.test(normalized)) {
33
+ const file = resolve(root, normalized);
34
+ return existsSync(file) && statSync(file).isFile() ? [file] : [];
35
+ }
36
+ const firstWild = segments.findIndex(segment => WILDCARD.test(segment));
37
+ const base = resolve(root, segments.slice(0, firstWild).join('/') || '.');
38
+ if (!existsSync(base)) return [];
39
+ const matcher = toRegExp(segments.slice(firstWild).join('/'));
40
+ const found = [];
41
+ const walk = (directory, prefix) => {
42
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
43
+ if (entry.name === 'node_modules') continue;
44
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
45
+ if (entry.isDirectory()) walk(resolve(directory, entry.name), relative);
46
+ else if (matcher.test(relative)) found.push(resolve(directory, entry.name));
47
+ }
48
+ };
49
+ walk(base, '');
50
+ return found.sort();
51
+ }
@@ -0,0 +1,71 @@
1
+ // tools/codemod/lib/library.mjs
2
+ import { resolve } from 'node:path';
3
+
4
+ const normalize = file => file.replaceAll('\\', '/');
5
+
6
+ /**
7
+ * Decides which declarations belong to DI Bag and names their owner.
8
+ * With `libraryRoots` the library is those directories (this repository runs with `src` and `dist`);
9
+ * without any, the library is every `node_modules/di-bag/` directory.
10
+ */
11
+ export function createLibrary({ ts, checker, root, libraryRoots = [] }) {
12
+ const prefixes = libraryRoots.map(directory => `${normalize(resolve(root, directory))}/`);
13
+ const isLibraryFile = fileName => {
14
+ const file = normalize(fileName);
15
+ return prefixes.length === 0 ? file.includes('/node_modules/di-bag/') : prefixes.some(prefix => file.startsWith(prefix));
16
+ };
17
+
18
+ /** The symbol a name refers to, with import aliases followed. */
19
+ function symbolAt(node) {
20
+ let symbol = checker.getSymbolAtLocation(node);
21
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
22
+ return symbol;
23
+ }
24
+
25
+ /**
26
+ * The owner of a member declaration: the nearest enclosing named declaration.
27
+ * A class, interface or type alias gives `Name`; a function gives `name()`;
28
+ * a method gives `Owner.method()`. A type literal written inline in a signature
29
+ * therefore belongs to that function or method.
30
+ */
31
+ function ownerOf(declaration) {
32
+ for (let node = declaration.parent; node; node = node.parent) {
33
+ if ((ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && node.name) return node.name.text;
34
+ if (ts.isFunctionDeclaration(node) && node.name) return `${node.name.text}()`;
35
+ if ((ts.isMethodDeclaration(node) || ts.isMethodSignature(node)) && ts.isIdentifier(node.name)) {
36
+ const container = node.parent;
37
+ if ((ts.isClassDeclaration(container) || ts.isInterfaceDeclaration(container)) && container.name) return `${container.name.text}.${node.name.text}()`;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ /** Library members plus whether every declaration of the symbol belongs to a named library owner. */
44
+ function memberCoverage(symbol) {
45
+ const members = new Map();
46
+ const declarations = symbol?.declarations ?? [];
47
+ let complete = declarations.length > 0;
48
+ for (const declaration of declarations) {
49
+ if (!isLibraryFile(declaration.getSourceFile().fileName)) { complete = false; continue; }
50
+ const owner = ownerOf(declaration);
51
+ if (owner === undefined) { complete = false; continue; }
52
+ members.set(`${owner}.${symbol.name}`, { owner, name: symbol.name });
53
+ }
54
+ return { members: [...members.values()], complete };
55
+ }
56
+
57
+ /** Every distinct `{ owner, name }` a member symbol is declared as inside the library. */
58
+ const membersOf = symbol => memberCoverage(symbol).members;
59
+
60
+ /** The exported name when the symbol is a top-level declaration of the library, else undefined. */
61
+ function exportNameOf(symbol) {
62
+ for (const declaration of symbol?.declarations ?? []) {
63
+ if (!isLibraryFile(declaration.getSourceFile().fileName)) continue;
64
+ const holder = ts.isVariableDeclaration(declaration) ? declaration.parent?.parent : declaration;
65
+ if (holder?.parent && ts.isSourceFile(holder.parent)) return symbol.name;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ return { isLibraryFile, symbolAt, membersOf, memberCoverage, exportNameOf };
71
+ }
@@ -0,0 +1,33 @@
1
+ // tools/codemod/lib/load-typescript.mjs
2
+ import { createRequire } from 'node:module';
3
+ import { resolve } from 'node:path';
4
+
5
+ function atLeast(version, minimum) {
6
+ const parts = String(version).split(/[.-]/).map(Number);
7
+ for (let index = 0; index < minimum.length; index++) {
8
+ if ((parts[index] ?? 0) !== minimum[index]) return (parts[index] ?? 0) > minimum[index];
9
+ }
10
+ return true;
11
+ }
12
+
13
+ /**
14
+ * The project's `typescript` when it exposes the compiler API at 6.0.3 or later, else the bundled copy.
15
+ * A newer project compiler without a compatible JavaScript API is analyzed with the bundled 6.
16
+ * @param {string} from - A directory inside the project.
17
+ * @returns {{ ts: typeof import('typescript'), version: string, source: 'project' | 'bundled' }}
18
+ */
19
+ export function loadTypeScript(from) {
20
+ const bundledPath = createRequire(import.meta.url).resolve('typescript');
21
+ try {
22
+ const require = createRequire(resolve(from, 'package.json'));
23
+ const path = require.resolve('typescript');
24
+ const candidate = require(path);
25
+ if (path !== bundledPath && typeof candidate.createProgram === 'function' && atLeast(candidate.version, [6, 0, 3])) {
26
+ return { ts: candidate, version: candidate.version, source: 'project' };
27
+ }
28
+ } catch {
29
+ // No resolvable typescript in the project.
30
+ }
31
+ const bundled = createRequire(import.meta.url)(bundledPath);
32
+ return { ts: bundled, version: bundled.version, source: 'bundled' };
33
+ }