uql-codemod 0.23.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.md +22 -0
- package/README.md +62 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +26 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +40 -0
- package/dist/edits.d.ts +23 -0
- package/dist/edits.js +31 -0
- package/dist/fieldType.d.ts +19 -0
- package/dist/fieldType.js +64 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +56 -0
- package/dist/transform.d.ts +12 -0
- package/dist/transform.js +321 -0
- package/dist/tsconfig.d.ts +14 -0
- package/dist/tsconfig.js +48 -0
- package/package.json +46 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2015-present UQL Contributors
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# uql-codemod
|
|
2
|
+
|
|
3
|
+
Migrates [uql-orm](https://uql-orm.dev) entities from the legacy TypeScript decorators to the TC39
|
|
4
|
+
standard spec. One command, on your own source, with a dry run first:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx uql-codemod --project=tsconfig.json --dry-run
|
|
8
|
+
npx uql-codemod --project=tsconfig.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Flag | |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `--project=<path>` | `tsconfig.json` to read. Required in practice: the codemod needs a real program, not a parser, because it writes down the types `design:type` used to report at runtime. Defaults to `./tsconfig.json`. |
|
|
14
|
+
| `--dry-run` | Report what would change and touch nothing. |
|
|
15
|
+
| `--include=<a,b>` | Only files whose path contains one of these fragments. |
|
|
16
|
+
|
|
17
|
+
Only the `--flag=value` form is read, and anything unrecognised is an error: a misspelled `--dry-run`
|
|
18
|
+
would otherwise rewrite the project for real. It exits `0` when nothing is left for you, `1` when
|
|
19
|
+
something is, and `2` when it could not start at all - a bad argument, or a project it cannot read.
|
|
20
|
+
|
|
21
|
+
## What it rewrites
|
|
22
|
+
|
|
23
|
+
- `@Field()` and `@Field({ ... })` with no `type`, and `@Id()`: inserts the `type` the property's
|
|
24
|
+
declared TypeScript type implies. Skipped when `references` is present, because schema generation
|
|
25
|
+
resolves that column from the referenced primary key.
|
|
26
|
+
- Bare relation decorators: inserts `entity: () => X`, unwrapping arrays and the old `Relation<T>` alias.
|
|
27
|
+
- `Relation<T>` becomes `T`, and its import goes with the last usage - `uql-orm` no longer exports it.
|
|
28
|
+
A usage somewhere the codemod does not reach, such as a type alias, keeps the import and is reported.
|
|
29
|
+
- `@Transactional()` methods: drops the `@InjectQuerier() querier?: Querier` parameter and rewrites the
|
|
30
|
+
body to `currentQuerier()`.
|
|
31
|
+
- `import 'reflect-metadata'` goes. Removing the package from your `package.json` is left to you.
|
|
32
|
+
- `tsconfig.json`: removes `experimentalDecorators` and `emitDecoratorMetadata`, keeping the rest of the
|
|
33
|
+
file - comments and formatting included - exactly as written.
|
|
34
|
+
|
|
35
|
+
## What it refuses to do
|
|
36
|
+
|
|
37
|
+
It reports rather than guesses, and exits non-zero when anything is left for you:
|
|
38
|
+
|
|
39
|
+
- **`target: esnext`** is reported, not changed. Removing the line falls back to the compiler default
|
|
40
|
+
(`es5` for `tsc`), and choosing a replacement means guessing which era the project targets. Any dated
|
|
41
|
+
target works; `esnext` is the one where TypeScript emits decorator syntax untransformed.
|
|
42
|
+
- **A value inherited through `extends`** cannot be edited here, so the base config is reported instead.
|
|
43
|
+
- **`@Log()` and `@Serialized()`** are reported and left in place. They no longer exist, and what to do
|
|
44
|
+
instead is a judgement call.
|
|
45
|
+
- **Options it cannot read** (`@Field(sharedOptions)`, a spread, or a `@Field` never called at all) are
|
|
46
|
+
left exactly as written. Replacing an argument it cannot parse would silently drop the options.
|
|
47
|
+
- **A property whose type it cannot map to a column** (a `Json<T>`, a vector, a union that disagrees with
|
|
48
|
+
itself) is reported as `needs a decision`. Those always had to declare `type` by hand anyway, because
|
|
49
|
+
`design:type` reported the useless `Object`/`Array` for them.
|
|
50
|
+
- **A branded string id**, e.g. `type UUID = \`${string}-${string}\``, gets `type: String` plus a
|
|
51
|
+
`worth a look` note. That is a real fork: `String` generates a text column, `'uuid'` generates a native
|
|
52
|
+
one, and only you know which the database has.
|
|
53
|
+
|
|
54
|
+
## After it runs
|
|
55
|
+
|
|
56
|
+
`tsc` is the rest of the migration, and that is the point: the annotations the codemod inserts are now
|
|
57
|
+
*checked* against the properties they describe, so anything it got wrong is a compile error rather than a
|
|
58
|
+
silently wrong column. See the [upgrade guide](https://uql-orm.dev/upgrade-guide).
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from './cli.js';
|
|
3
|
+
import { run } from './index.js';
|
|
4
|
+
const USAGE = 'usage: uql-codemod [--project=<path>] [--include=<a,b>] [--dry-run]';
|
|
5
|
+
// Exit 2 is "could not start", so a script can tell it apart from exit 1, "there is work left".
|
|
6
|
+
const stop = (message) => {
|
|
7
|
+
console.error(message);
|
|
8
|
+
process.exit(2);
|
|
9
|
+
};
|
|
10
|
+
const { options, errors } = parseArgs(process.argv.slice(2));
|
|
11
|
+
if (errors.length) {
|
|
12
|
+
stop([...errors, USAGE].join('\n'));
|
|
13
|
+
}
|
|
14
|
+
// A wrong `--project` is the likeliest mistake here, and reporting it as a stack trace read as a crash.
|
|
15
|
+
const summary = await run(options).catch((error) => stop(error instanceof Error ? error.message : USAGE));
|
|
16
|
+
for (const line of summary.notes) {
|
|
17
|
+
console.log(`worth a look: ${line}`);
|
|
18
|
+
}
|
|
19
|
+
for (const line of summary.unresolved) {
|
|
20
|
+
console.error(`needs a decision: ${line}`);
|
|
21
|
+
}
|
|
22
|
+
console.log(`${summary.changed.length} file(s) ${options.dryRun ? 'would change' : 'changed'}`);
|
|
23
|
+
if (summary.unresolved.length) {
|
|
24
|
+
console.error(`${summary.unresolved.length} property(ies) left untouched; see above`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RunOptions } from './index.js';
|
|
2
|
+
export type ParsedArgs = {
|
|
3
|
+
readonly options: RunOptions;
|
|
4
|
+
/** Why the arguments were rejected. Nothing runs while this is non-empty. */
|
|
5
|
+
readonly errors: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Reads the command line, rejecting anything it does not recognise.
|
|
9
|
+
*
|
|
10
|
+
* Unknown arguments are an error rather than being ignored: a misspelled `--dry-run` would otherwise
|
|
11
|
+
* rewrite the project for real, which is the one mistake this tool must not let someone make quietly.
|
|
12
|
+
* Only the `--flag=value` form is accepted, so a stray `--project foo` cannot silently fall back to the
|
|
13
|
+
* default config either.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseArgs(argv: readonly string[]): ParsedArgs;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const VALUE_FLAGS = new Set(['project', 'include']);
|
|
2
|
+
/**
|
|
3
|
+
* Reads the command line, rejecting anything it does not recognise.
|
|
4
|
+
*
|
|
5
|
+
* Unknown arguments are an error rather than being ignored: a misspelled `--dry-run` would otherwise
|
|
6
|
+
* rewrite the project for real, which is the one mistake this tool must not let someone make quietly.
|
|
7
|
+
* Only the `--flag=value` form is accepted, so a stray `--project foo` cannot silently fall back to the
|
|
8
|
+
* default config either.
|
|
9
|
+
*/
|
|
10
|
+
export function parseArgs(argv) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
const values = new Map();
|
|
13
|
+
let dryRun = false;
|
|
14
|
+
for (const arg of argv) {
|
|
15
|
+
if (arg === '--dry-run') {
|
|
16
|
+
dryRun = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const separator = arg.indexOf('=');
|
|
20
|
+
const name = arg.slice(2, separator === -1 ? undefined : separator);
|
|
21
|
+
if (!arg.startsWith('--') || !VALUE_FLAGS.has(name)) {
|
|
22
|
+
errors.push(`unknown argument '${arg}'`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const value = arg.slice(separator + 1);
|
|
26
|
+
if (separator === -1 || !value) {
|
|
27
|
+
errors.push(`'--${name}' needs a value, as '--${name}=<value>'`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
values.set(name, value);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
options: {
|
|
34
|
+
project: values.get('project') ?? 'tsconfig.json',
|
|
35
|
+
dryRun,
|
|
36
|
+
include: values.get('include')?.split(',').filter(Boolean),
|
|
37
|
+
},
|
|
38
|
+
errors,
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/edits.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type ts from 'typescript';
|
|
2
|
+
/** One text replacement in a file. */
|
|
3
|
+
export type Edit = {
|
|
4
|
+
readonly start: number;
|
|
5
|
+
readonly end: number;
|
|
6
|
+
readonly text: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Applies edits back to front, so an earlier edit cannot shift the offsets of a later one.
|
|
10
|
+
*
|
|
11
|
+
* Replacing text rather than reprinting the file is what keeps the rest of it byte-for-byte: a codemod
|
|
12
|
+
* that reformats everything it touches buries its own change in the diff.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applyEdits(text: string, edits: readonly Edit[]): string;
|
|
15
|
+
/**
|
|
16
|
+
* Removes one element of a comma-separated list - a parameter, a named import, a JSON property - taking
|
|
17
|
+
* the separator that would otherwise dangle. The last element gives up the comma before it; any other
|
|
18
|
+
* gives up the one after.
|
|
19
|
+
*
|
|
20
|
+
* The source file is passed explicitly because a tree from `ts.parseJsonText` carries no
|
|
21
|
+
* back-reference, so `getStart()` cannot find one on its own.
|
|
22
|
+
*/
|
|
23
|
+
export declare function removeFromList(items: readonly ts.Node[], item: ts.Node, source: ts.SourceFile): Edit;
|
package/dist/edits.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies edits back to front, so an earlier edit cannot shift the offsets of a later one.
|
|
3
|
+
*
|
|
4
|
+
* Replacing text rather than reprinting the file is what keeps the rest of it byte-for-byte: a codemod
|
|
5
|
+
* that reformats everything it touches buries its own change in the diff.
|
|
6
|
+
*/
|
|
7
|
+
export function applyEdits(text, edits) {
|
|
8
|
+
let result = text;
|
|
9
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
10
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Removes one element of a comma-separated list - a parameter, a named import, a JSON property - taking
|
|
16
|
+
* the separator that would otherwise dangle. The last element gives up the comma before it; any other
|
|
17
|
+
* gives up the one after.
|
|
18
|
+
*
|
|
19
|
+
* The source file is passed explicitly because a tree from `ts.parseJsonText` carries no
|
|
20
|
+
* back-reference, so `getStart()` cannot find one on its own.
|
|
21
|
+
*/
|
|
22
|
+
export function removeFromList(items, item, source) {
|
|
23
|
+
const at = items.indexOf(item);
|
|
24
|
+
const next = items[at + 1];
|
|
25
|
+
const previous = items[at - 1];
|
|
26
|
+
return {
|
|
27
|
+
start: previous && !next ? previous.getEnd() : item.getStart(source),
|
|
28
|
+
end: next ? next.getStart(source) : item.getEnd(),
|
|
29
|
+
text: '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/** A string type carrying more than `string`, e.g. `` type UUID = `${string}-${string}` ``. */
|
|
3
|
+
export declare function isBrandedString(type: ts.Type): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* The `FieldType` expression to write for a property declared as `type`, or `undefined` when the shape is
|
|
6
|
+
* one a human has to decide on.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `TypeFor` in `uql-orm`, in the direction the codemod needs: from the property's TypeScript type
|
|
9
|
+
* to the `type` option that reflection used to supply. Only the cases reflection could actually produce
|
|
10
|
+
* are handled. A `Json<T>` or vector property already had to declare its `type` explicitly, because
|
|
11
|
+
* `design:type` reported the useless `Object`/`Array` for them, so any such property reaching here is
|
|
12
|
+
* genuinely ambiguous and is reported rather than guessed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function fieldTypeFor(type: ts.Type): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The entity name a relation property points at: `Company` for `Company`, `Company[]`, and
|
|
17
|
+
* `Relation<Company>` alike, since `Relation<T>` is an alias for `T` and erases before this runs.
|
|
18
|
+
*/
|
|
19
|
+
export declare function relationTargetFor(type: ts.Type, checker: ts.TypeChecker): string | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/** A string type carrying more than `string`, e.g. `` type UUID = `${string}-${string}` ``. */
|
|
3
|
+
export function isBrandedString(type) {
|
|
4
|
+
const branded = ts.TypeFlags.TemplateLiteral | ts.TypeFlags.StringMapping;
|
|
5
|
+
return type.isUnion() ? type.types.some((t) => !!(t.getFlags() & branded)) : !!(type.getFlags() & branded);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Reads a union by requiring its arms to agree, which is what an `'a' | 'b'` enum or a `T | null` looks
|
|
9
|
+
* like. `undefined` and `null` arms are dropped first: they carry no column type of their own.
|
|
10
|
+
*/
|
|
11
|
+
function foldUnion(type, read) {
|
|
12
|
+
const arms = type.types.filter((arm) => !(arm.getFlags() & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)));
|
|
13
|
+
const readings = new Set(arms.map(read));
|
|
14
|
+
return readings.size === 1 ? [...readings][0] : undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The `FieldType` expression to write for a property declared as `type`, or `undefined` when the shape is
|
|
18
|
+
* one a human has to decide on.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors `TypeFor` in `uql-orm`, in the direction the codemod needs: from the property's TypeScript type
|
|
21
|
+
* to the `type` option that reflection used to supply. Only the cases reflection could actually produce
|
|
22
|
+
* are handled. A `Json<T>` or vector property already had to declare its `type` explicitly, because
|
|
23
|
+
* `design:type` reported the useless `Object`/`Array` for them, so any such property reaching here is
|
|
24
|
+
* genuinely ambiguous and is reported rather than guessed.
|
|
25
|
+
*/
|
|
26
|
+
export function fieldTypeFor(type) {
|
|
27
|
+
const flags = type.getFlags();
|
|
28
|
+
if (type.isUnion()) {
|
|
29
|
+
return foldUnion(type, fieldTypeFor);
|
|
30
|
+
}
|
|
31
|
+
// `TemplateLiteral` and `StringMapping` cover the branded-string types projects use for ids, e.g.
|
|
32
|
+
// `type UUID = `${string}-${string}-${string}-${string}-${string}``, which are still string columns.
|
|
33
|
+
const stringLike = ts.TypeFlags.String | ts.TypeFlags.StringLiteral | ts.TypeFlags.TemplateLiteral | ts.TypeFlags.StringMapping;
|
|
34
|
+
if (flags & stringLike)
|
|
35
|
+
return 'String';
|
|
36
|
+
if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral))
|
|
37
|
+
return 'Number';
|
|
38
|
+
if (flags & (ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral))
|
|
39
|
+
return 'BigInt';
|
|
40
|
+
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral))
|
|
41
|
+
return 'Boolean';
|
|
42
|
+
// Matched by declared symbol rather than by the printed type: both of these became generic in the
|
|
43
|
+
// modern lib, so `Uint8Array` prints as `Uint8Array<ArrayBufferLike>` and never compared equal.
|
|
44
|
+
const name = type.getSymbol()?.getName();
|
|
45
|
+
if (name === 'Date')
|
|
46
|
+
return 'Date';
|
|
47
|
+
if (name === 'Buffer' || name === 'Uint8Array')
|
|
48
|
+
return "'blob'";
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The entity name a relation property points at: `Company` for `Company`, `Company[]`, and
|
|
53
|
+
* `Relation<Company>` alike, since `Relation<T>` is an alias for `T` and erases before this runs.
|
|
54
|
+
*/
|
|
55
|
+
export function relationTargetFor(type, checker) {
|
|
56
|
+
if (type.isUnion()) {
|
|
57
|
+
return foldUnion(type, (arm) => relationTargetFor(arm, checker));
|
|
58
|
+
}
|
|
59
|
+
const elementType = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
|
|
60
|
+
const target = elementType ?? type;
|
|
61
|
+
const symbol = target.getSymbol();
|
|
62
|
+
const name = symbol?.getName();
|
|
63
|
+
return name && name !== '__type' && /^[A-Z][A-Za-z0-9_]*$/.test(name) ? name : undefined;
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export * from './transform.js';
|
|
2
|
+
export * from './tsconfig.js';
|
|
3
|
+
export type RunOptions = {
|
|
4
|
+
/** Path to the project's `tsconfig.json`; the codemod needs a real program to read property types. */
|
|
5
|
+
readonly project: string;
|
|
6
|
+
/** Report what would change without touching anything. */
|
|
7
|
+
readonly dryRun?: boolean;
|
|
8
|
+
/** Restrict the run to files whose path contains one of these fragments. */
|
|
9
|
+
readonly include?: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
export type RunSummary = {
|
|
12
|
+
readonly changed: readonly string[];
|
|
13
|
+
readonly unresolved: readonly string[];
|
|
14
|
+
readonly notes: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Rewrites a project's entities for the standard decorator spec.
|
|
18
|
+
*
|
|
19
|
+
* @remarks Needs a type checker, not just a parser: the whole point is to write down the types that
|
|
20
|
+
* `design:type` used to report at runtime, and only the checker knows what `role?: Role` resolves to.
|
|
21
|
+
*/
|
|
22
|
+
export declare function run({ project, dryRun, include }: RunOptions): Promise<RunSummary>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
import { transformFile } from './transform.js';
|
|
5
|
+
import { transformTsconfig } from './tsconfig.js';
|
|
6
|
+
export * from './transform.js';
|
|
7
|
+
export * from './tsconfig.js';
|
|
8
|
+
/**
|
|
9
|
+
* Rewrites a project's entities for the standard decorator spec.
|
|
10
|
+
*
|
|
11
|
+
* @remarks Needs a type checker, not just a parser: the whole point is to write down the types that
|
|
12
|
+
* `design:type` used to report at runtime, and only the checker knows what `role?: Role` resolves to.
|
|
13
|
+
*/
|
|
14
|
+
export async function run({ project, dryRun, include }) {
|
|
15
|
+
// Absolute, because `parseJsonConfigFileContent` resolves every discovered file against the base path
|
|
16
|
+
// and a relative one yields file names the program cannot then open.
|
|
17
|
+
const configPath = resolve(project);
|
|
18
|
+
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
19
|
+
if (config.error) {
|
|
20
|
+
throw new TypeError(ts.flattenDiagnosticMessageText(config.error.messageText, ' '));
|
|
21
|
+
}
|
|
22
|
+
const host = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath));
|
|
23
|
+
const program = ts.createProgram(host.fileNames, {
|
|
24
|
+
...host.options,
|
|
25
|
+
// The codemod only ever reads types, so the emit target is irrelevant. It is pinned because a
|
|
26
|
+
// project may name a target newer than this TypeScript understands, which would otherwise be
|
|
27
|
+
// reported as a config error and leave the options half-applied.
|
|
28
|
+
target: ts.ScriptTarget.ESNext,
|
|
29
|
+
noEmit: true,
|
|
30
|
+
});
|
|
31
|
+
const checker = program.getTypeChecker();
|
|
32
|
+
const results = [];
|
|
33
|
+
for (const source of program.getSourceFiles()) {
|
|
34
|
+
if (source.isDeclarationFile || /[/\\]node_modules[/\\]/.test(source.fileName))
|
|
35
|
+
continue;
|
|
36
|
+
if (include?.length && !include.some((fragment) => source.fileName.includes(fragment)))
|
|
37
|
+
continue;
|
|
38
|
+
const result = transformFile(source, checker);
|
|
39
|
+
if (result.changed || result.unresolved.length || result.notes.length)
|
|
40
|
+
results.push(result);
|
|
41
|
+
}
|
|
42
|
+
// The config is rewritten too: leaving `experimentalDecorators` on is what keeps a project compiling
|
|
43
|
+
// against the old spec, so a codemod that only touched the sources would leave it half-migrated.
|
|
44
|
+
const tsconfig = transformTsconfig(configPath, ts.sys.readFile(configPath) ?? '');
|
|
45
|
+
if (!dryRun) {
|
|
46
|
+
await Promise.all(results.filter((r) => r.changed).map((r) => writeFile(r.fileName, r.text, 'utf8')));
|
|
47
|
+
if (tsconfig.changed) {
|
|
48
|
+
await writeFile(configPath, tsconfig.text, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
changed: [...results.filter((r) => r.changed).map((r) => r.fileName), ...(tsconfig.changed ? [configPath] : [])],
|
|
53
|
+
unresolved: [...results.flatMap((r) => r.unresolved), ...tsconfig.unresolved],
|
|
54
|
+
notes: results.flatMap((r) => r.notes),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export type FileResult = {
|
|
3
|
+
readonly fileName: string;
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly changed: boolean;
|
|
6
|
+
/** Properties the codemod refused to guess at, each needing a human. */
|
|
7
|
+
readonly unresolved: readonly string[];
|
|
8
|
+
/** Rewrites that are correct but worth a second look. */
|
|
9
|
+
readonly notes: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
/** Rewrites one source file for the standard decorator spec. */
|
|
12
|
+
export declare function transformFile(source: ts.SourceFile, checker: ts.TypeChecker): FileResult;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { applyEdits, removeFromList } from './edits.js';
|
|
3
|
+
import { fieldTypeFor, isBrandedString, relationTargetFor } from './fieldType.js';
|
|
4
|
+
const FIELD_DECORATORS = new Set(['Field', 'Id']);
|
|
5
|
+
const RELATION_DECORATORS = new Set(['OneToOne', 'ManyToOne', 'OneToMany', 'ManyToMany']);
|
|
6
|
+
/**
|
|
7
|
+
* Decorators that no longer exist, and that the codemod does not remove for you.
|
|
8
|
+
*
|
|
9
|
+
* A standard-spec decorator cannot preserve a generic method's signature, which is why these went. What
|
|
10
|
+
* to do instead is a judgement call - inline the logging, drop the serialisation - so the usage is
|
|
11
|
+
* reported and left in place. Removing only the import would swap a clear error for a puzzling one.
|
|
12
|
+
*/
|
|
13
|
+
const REMOVED_DECORATORS = new Set(['Log', 'Serialized']);
|
|
14
|
+
function decoratorName(node) {
|
|
15
|
+
const call = ts.isCallExpression(node.expression) ? node.expression : undefined;
|
|
16
|
+
const target = call?.expression ?? node.expression;
|
|
17
|
+
return ts.isIdentifier(target) ? target.text : undefined;
|
|
18
|
+
}
|
|
19
|
+
function decoratorOptions(node) {
|
|
20
|
+
if (!ts.isCallExpression(node.expression)) {
|
|
21
|
+
// `@Field` rather than `@Field()`: there is no argument list to write into, and adding one would mean
|
|
22
|
+
// guessing that this is the decorator factory the codemod thinks it is.
|
|
23
|
+
return { kind: 'opaque', reason: 'it is used without being called' };
|
|
24
|
+
}
|
|
25
|
+
const [first] = node.expression.arguments;
|
|
26
|
+
if (!first) {
|
|
27
|
+
return { kind: 'empty', call: node.expression };
|
|
28
|
+
}
|
|
29
|
+
if (!ts.isObjectLiteralExpression(first)) {
|
|
30
|
+
return { kind: 'opaque', reason: `its options are passed as '${first.getText()}'` };
|
|
31
|
+
}
|
|
32
|
+
return first.properties.some(ts.isSpreadAssignment)
|
|
33
|
+
? { kind: 'opaque', reason: 'its options object spreads another' }
|
|
34
|
+
: { kind: 'literal', node: first };
|
|
35
|
+
}
|
|
36
|
+
function hasProperty(options, name) {
|
|
37
|
+
return (options.kind === 'literal' &&
|
|
38
|
+
options.node.properties.some((prop) => prop.name && ts.isIdentifier(prop.name) && prop.name.text === name));
|
|
39
|
+
}
|
|
40
|
+
/** Inserts `option` into the decorator's options object, creating one when the call has no arguments. */
|
|
41
|
+
function insertOption(options, option) {
|
|
42
|
+
if (options.kind === 'empty') {
|
|
43
|
+
// `@Field()` -> `@Field({ ... })`: the argument list is empty, so this only ever inserts.
|
|
44
|
+
const { arguments: args } = options.call;
|
|
45
|
+
return { start: args.pos, end: args.end, text: `{ ${option} }` };
|
|
46
|
+
}
|
|
47
|
+
const first = options.node.properties[0];
|
|
48
|
+
return first
|
|
49
|
+
? { start: first.getStart(), end: first.getStart(), text: `${option}, ` }
|
|
50
|
+
: { start: options.node.getStart(), end: options.node.getEnd(), text: `{ ${option} }` };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Writes one option the decorator can no longer infer at runtime, or records why it could not.
|
|
54
|
+
*
|
|
55
|
+
* Returns the property's type when an edit was written, so a caller can say something about what was
|
|
56
|
+
* just decided. Nothing is written for options the codemod cannot read: replacing them would drop
|
|
57
|
+
* whatever the author put there.
|
|
58
|
+
*/
|
|
59
|
+
function addInferredOption(decorator, node, ctx, spec) {
|
|
60
|
+
const options = decoratorOptions(decorator);
|
|
61
|
+
if (spec.satisfiedBy.some((name) => hasProperty(options, name))) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (options.kind === 'opaque') {
|
|
65
|
+
ctx.unresolved.push(`${ctx.describe(node)}: cannot add '${spec.option}' because ${options.reason}`);
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const type = ctx.checker.getTypeAtLocation(node);
|
|
69
|
+
const value = spec.infer(type);
|
|
70
|
+
if (!value) {
|
|
71
|
+
ctx.unresolved.push(`${ctx.describe(node)}: cannot infer '${spec.option}' for ${ctx.checker.typeToString(type)}`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
ctx.edits.push(insertOption(options, `${spec.option}: ${value}`));
|
|
75
|
+
return type;
|
|
76
|
+
}
|
|
77
|
+
/** Writes the `type` that `design:type` used to supply at runtime. */
|
|
78
|
+
function addFieldType(decorator, node, ctx) {
|
|
79
|
+
const type = addInferredOption(decorator, node, ctx, {
|
|
80
|
+
option: 'type',
|
|
81
|
+
// A `references` field deliberately has no `type`: the column resolves from the primary key it
|
|
82
|
+
// points at, which also carries that key's `columnType` and length.
|
|
83
|
+
satisfiedBy: ['type', 'references'],
|
|
84
|
+
infer: fieldTypeFor,
|
|
85
|
+
});
|
|
86
|
+
// A branded id type (`type UUID = `${string}-${string}-...``) erased to `String` under reflection, so
|
|
87
|
+
// `String` is what keeps the existing column. It is often not what was wanted, though, and switching
|
|
88
|
+
// to `'uuid'` changes the generated column, so say so rather than decide.
|
|
89
|
+
if (type && isBrandedString(type)) {
|
|
90
|
+
ctx.notes.push(`${ctx.describe(node)}: set 'type: ${fieldTypeFor(type)}' for ` +
|
|
91
|
+
`${ctx.checker.typeToString(type)}, matching the previous behaviour. If the column should be ` +
|
|
92
|
+
"'uuid', change it deliberately: that alters the schema.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Writes the `entity` getter relations can no longer infer. */
|
|
96
|
+
function addRelationEntity(decorator, node, ctx) {
|
|
97
|
+
addInferredOption(decorator, node, ctx, {
|
|
98
|
+
option: 'entity',
|
|
99
|
+
satisfiedBy: ['entity'],
|
|
100
|
+
infer: (t) => {
|
|
101
|
+
const target = relationTargetFor(t, ctx.checker);
|
|
102
|
+
return target && `() => ${target}`;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Drops `declare` from a decorated field. A `declare` field emits nothing, so the standard spec has
|
|
108
|
+
* nothing to decorate and rejects it ("Decorators are not valid here"). Legacy decorators tolerated it
|
|
109
|
+
* because they only ever reached the class through `target.constructor`.
|
|
110
|
+
*/
|
|
111
|
+
function dropDeclare(node, ctx) {
|
|
112
|
+
const modifier = node.modifiers?.find((m) => m.kind === ts.SyntaxKind.DeclareKeyword);
|
|
113
|
+
if (modifier) {
|
|
114
|
+
// Through the whitespace that followed it, rather than a fixed one character: two spaces or a line
|
|
115
|
+
// break left the rest behind.
|
|
116
|
+
const text = node.getSourceFile().text;
|
|
117
|
+
let end = modifier.getEnd();
|
|
118
|
+
while (/\s/.test(text[end] ?? '')) {
|
|
119
|
+
end += 1;
|
|
120
|
+
}
|
|
121
|
+
ctx.edits.push({ start: modifier.getStart(), end, text: '' });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Unwraps `Relation<Company>` to `Company`. The alias is `type Relation<T> = T`, and it existed only
|
|
126
|
+
* because reflection stored the property's type at class-definition time, which forced a real import and
|
|
127
|
+
* so created the circular-import problem it was working around.
|
|
128
|
+
*/
|
|
129
|
+
function unwrapRelationAlias(node, ctx) {
|
|
130
|
+
const declared = node.type;
|
|
131
|
+
if (declared &&
|
|
132
|
+
ts.isTypeReferenceNode(declared) &&
|
|
133
|
+
ts.isIdentifier(declared.typeName) &&
|
|
134
|
+
declared.typeName.text === 'Relation' &&
|
|
135
|
+
declared.typeArguments?.length === 1) {
|
|
136
|
+
ctx.edits.push({ start: declared.getStart(), end: declared.getEnd(), text: declared.typeArguments[0].getText() });
|
|
137
|
+
ctx.relationAlias.unwrapped += 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Counts `Relation<T>` wherever it appears, including the places this codemod does not rewrite. */
|
|
141
|
+
function countRelationAlias(node, ctx) {
|
|
142
|
+
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Relation') {
|
|
143
|
+
ctx.relationAlias.seen += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Rewrites an `@InjectQuerier()` parameter into a `currentQuerier()` call.
|
|
148
|
+
*
|
|
149
|
+
* The standard spec has no parameter decorators, so the querier travels through async-local storage and
|
|
150
|
+
* is read inside the body. Only the plain shape is rewritten - a named parameter in a method with a body.
|
|
151
|
+
* A default value or a destructured parameter is reported: the author was doing something the codemod
|
|
152
|
+
* would have to guess at.
|
|
153
|
+
*/
|
|
154
|
+
function rewriteInjectQuerier(method, ctx) {
|
|
155
|
+
for (const parameter of method.parameters) {
|
|
156
|
+
if (!ts.getDecorators(parameter)?.some((d) => decoratorName(d) === 'InjectQuerier')) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!method.body || !ts.isIdentifier(parameter.name) || parameter.initializer) {
|
|
160
|
+
ctx.unresolved.push(`${ctx.describe(parameter)}: cannot rewrite this '@InjectQuerier()' parameter`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
ctx.edits.push(removeFromList(method.parameters, parameter, method.getSourceFile()));
|
|
164
|
+
// The querier is read at the top of the body, keeping the name the parameter had.
|
|
165
|
+
const body = method.body;
|
|
166
|
+
const first = body.statements[0];
|
|
167
|
+
const indent = first ? ' '.repeat(first.getStart() - body.getSourceFile().getLineStarts()[ctx.lineOf(first)]) : '';
|
|
168
|
+
ctx.edits.push({
|
|
169
|
+
start: body.getStart() + 1,
|
|
170
|
+
end: body.getStart() + 1,
|
|
171
|
+
text: `\n${indent}const ${parameter.name.text} = currentQuerier();`,
|
|
172
|
+
});
|
|
173
|
+
ctx.imports.add('currentQuerier');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Removes a statement together with the rest of its line, so nothing is left blank behind it. */
|
|
177
|
+
function removeStatement(node) {
|
|
178
|
+
const source = node.getSourceFile();
|
|
179
|
+
const line = source.getLineAndCharacterOfPosition(node.getEnd()).line;
|
|
180
|
+
const nextLineStart = source.getLineStarts()[line + 1];
|
|
181
|
+
return { start: node.getStart(), end: nextLineStart ?? node.getEnd(), text: '' };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Brings the file's imports in line with what the rewrites left behind, in one pass:
|
|
185
|
+
*
|
|
186
|
+
* - `import 'reflect-metadata'` goes, since the polyfill only ever fed `design:type`.
|
|
187
|
+
* - `InjectQuerier` goes with it, and its import would otherwise not compile: the standard spec has no
|
|
188
|
+
* parameter decorators, so `uql-orm` no longer exports one.
|
|
189
|
+
* - whatever a rewrite needs (`currentQuerier`) is added, extending an existing `uql-orm` import rather
|
|
190
|
+
* than adding a second one.
|
|
191
|
+
*
|
|
192
|
+
* One function because all three edit the same statements, and doing them separately meant an import
|
|
193
|
+
* could be dropped and re-added, or a name added to a statement another edit had already removed.
|
|
194
|
+
*/
|
|
195
|
+
function reconcileImports(source, ctx) {
|
|
196
|
+
// Dropping runs first and hands over the `uql-orm` import it left standing: adding a name to a
|
|
197
|
+
// statement the other pass had already removed is the bug that made these one step to begin with.
|
|
198
|
+
addNeededImports(source, ctx, dropDeadImports(source, ctx));
|
|
199
|
+
}
|
|
200
|
+
/** Names `uql-orm` no longer exports. `Relation` joins them only when every usage was rewritten. */
|
|
201
|
+
function deadImportNames(source, ctx) {
|
|
202
|
+
const { seen, unwrapped } = ctx.relationAlias;
|
|
203
|
+
if (seen > unwrapped) {
|
|
204
|
+
ctx.unresolved.push(`${source.fileName}: ${seen - unwrapped} 'Relation<T>' reference(s) are somewhere this codemod does ` +
|
|
205
|
+
'not rewrite; unwrap them and drop the import by hand');
|
|
206
|
+
}
|
|
207
|
+
return new Set(seen > 0 && seen === unwrapped ? ['InjectQuerier', 'Relation'] : ['InjectQuerier']);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Removes `import 'reflect-metadata'` and any name that no longer exists, and returns the `uql-orm`
|
|
211
|
+
* named imports still standing - `undefined` when there are none to extend.
|
|
212
|
+
*/
|
|
213
|
+
function dropDeadImports(source, ctx) {
|
|
214
|
+
const dead = deadImportNames(source, ctx);
|
|
215
|
+
let host;
|
|
216
|
+
for (const statement of source.statements) {
|
|
217
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const module = statement.moduleSpecifier.text;
|
|
221
|
+
if (module === 'reflect-metadata' && !statement.importClause) {
|
|
222
|
+
ctx.edits.push(removeStatement(statement));
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const named = statement.importClause?.namedBindings;
|
|
226
|
+
if (!named || !ts.isNamedImports(named)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const dropped = named.elements.filter((element) => dead.has(element.name.text));
|
|
230
|
+
if (dropped.length === named.elements.length && !statement.importClause?.name) {
|
|
231
|
+
ctx.edits.push(removeStatement(statement));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const element of dropped) {
|
|
235
|
+
ctx.edits.push(removeFromList(named.elements, element, source));
|
|
236
|
+
}
|
|
237
|
+
if (module === 'uql-orm') {
|
|
238
|
+
host = named;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return host;
|
|
242
|
+
}
|
|
243
|
+
/** Adds what the rewrites need, extending `host` when there is one rather than adding a second import. */
|
|
244
|
+
function addNeededImports(source, ctx, host) {
|
|
245
|
+
const missing = [...ctx.imports].filter((name) => !host?.elements.some((element) => element.name.text === name));
|
|
246
|
+
if (!missing.length) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (host) {
|
|
250
|
+
const last = host.elements[host.elements.length - 1];
|
|
251
|
+
ctx.edits.push({ start: last.getEnd(), end: last.getEnd(), text: `, ${missing.join(', ')}` });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const position = source.statements[0]?.getStart() ?? 0;
|
|
255
|
+
ctx.edits.push({ start: position, end: position, text: `import { ${missing.join(', ')} } from 'uql-orm';\n` });
|
|
256
|
+
}
|
|
257
|
+
/** Everything the standard spec needs written onto one decorated property. */
|
|
258
|
+
function rewriteProperty(node, ctx) {
|
|
259
|
+
const decorators = ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
|
|
260
|
+
for (const decorator of decorators) {
|
|
261
|
+
const name = decoratorName(decorator);
|
|
262
|
+
if (!name) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (FIELD_DECORATORS.has(name)) {
|
|
266
|
+
addFieldType(decorator, node, ctx);
|
|
267
|
+
}
|
|
268
|
+
if (RELATION_DECORATORS.has(name)) {
|
|
269
|
+
addRelationEntity(decorator, node, ctx);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (decorators.length) {
|
|
273
|
+
dropDeclare(node, ctx);
|
|
274
|
+
}
|
|
275
|
+
unwrapRelationAlias(node, ctx);
|
|
276
|
+
}
|
|
277
|
+
/** Names a decorator that no longer exists, wherever it appears. */
|
|
278
|
+
function reportRemovedDecorators(node, ctx) {
|
|
279
|
+
if (!ts.canHaveDecorators(node)) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
for (const decorator of ts.getDecorators(node) ?? []) {
|
|
283
|
+
const name = decoratorName(decorator);
|
|
284
|
+
if (name && REMOVED_DECORATORS.has(name)) {
|
|
285
|
+
ctx.unresolved.push(`${ctx.describe(decorator)}: '@${name}()' was removed; delete it and its import`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Rewrites one source file for the standard decorator spec. */
|
|
290
|
+
export function transformFile(source, checker) {
|
|
291
|
+
const ctx = {
|
|
292
|
+
checker,
|
|
293
|
+
edits: [],
|
|
294
|
+
unresolved: [],
|
|
295
|
+
notes: [],
|
|
296
|
+
imports: new Set(),
|
|
297
|
+
relationAlias: { seen: 0, unwrapped: 0 },
|
|
298
|
+
describe: (node) => `${source.fileName}:${ctx.lineOf(node) + 1}`,
|
|
299
|
+
lineOf: (node) => source.getLineAndCharacterOfPosition(node.getStart()).line,
|
|
300
|
+
};
|
|
301
|
+
const visit = (node) => {
|
|
302
|
+
reportRemovedDecorators(node, ctx);
|
|
303
|
+
countRelationAlias(node, ctx);
|
|
304
|
+
if (ts.isPropertyDeclaration(node)) {
|
|
305
|
+
rewriteProperty(node, ctx);
|
|
306
|
+
}
|
|
307
|
+
else if (ts.isMethodDeclaration(node)) {
|
|
308
|
+
rewriteInjectQuerier(node, ctx);
|
|
309
|
+
}
|
|
310
|
+
ts.forEachChild(node, visit);
|
|
311
|
+
};
|
|
312
|
+
visit(source);
|
|
313
|
+
reconcileImports(source, ctx);
|
|
314
|
+
return {
|
|
315
|
+
fileName: source.fileName,
|
|
316
|
+
text: applyEdits(source.getFullText(), ctx.edits),
|
|
317
|
+
changed: ctx.edits.length > 0,
|
|
318
|
+
unresolved: ctx.unresolved,
|
|
319
|
+
notes: ctx.notes,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type TsconfigResult = {
|
|
2
|
+
readonly text: string;
|
|
3
|
+
readonly changed: boolean;
|
|
4
|
+
/** What the codemod will not decide, each needing a human. */
|
|
5
|
+
readonly unresolved: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Rewrites a `tsconfig.json` for the standard decorator spec.
|
|
9
|
+
*
|
|
10
|
+
* Edited as text through `ts.parseJsonText`, not by reading and re-serialising: a tsconfig is JSON with
|
|
11
|
+
* comments and someone's formatting, and rewriting the whole file to change two lines would throw both
|
|
12
|
+
* away.
|
|
13
|
+
*/
|
|
14
|
+
export declare function transformTsconfig(fileName: string, text: string): TsconfigResult;
|
package/dist/tsconfig.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { applyEdits, removeFromList } from './edits.js';
|
|
3
|
+
/** The flags the standard spec has no use for. Their presence is what keeps a project on the old one. */
|
|
4
|
+
const LEGACY_FLAGS = ['experimentalDecorators', 'emitDecoratorMetadata'];
|
|
5
|
+
/**
|
|
6
|
+
* Rewrites a `tsconfig.json` for the standard decorator spec.
|
|
7
|
+
*
|
|
8
|
+
* Edited as text through `ts.parseJsonText`, not by reading and re-serialising: a tsconfig is JSON with
|
|
9
|
+
* comments and someone's formatting, and rewriting the whole file to change two lines would throw both
|
|
10
|
+
* away.
|
|
11
|
+
*/
|
|
12
|
+
export function transformTsconfig(fileName, text) {
|
|
13
|
+
const source = ts.parseJsonText(fileName, text);
|
|
14
|
+
const root = source.statements[0]?.expression;
|
|
15
|
+
if (!root || !ts.isObjectLiteralExpression(root)) {
|
|
16
|
+
return { text, changed: false, unresolved: [`${fileName}: not an object, so nothing was changed`] };
|
|
17
|
+
}
|
|
18
|
+
const edits = [];
|
|
19
|
+
const unresolved = [];
|
|
20
|
+
const compilerOptions = propertyValue(root, 'compilerOptions');
|
|
21
|
+
if (compilerOptions && ts.isObjectLiteralExpression(compilerOptions)) {
|
|
22
|
+
for (const flag of LEGACY_FLAGS) {
|
|
23
|
+
const property = propertyAssignment(compilerOptions, flag);
|
|
24
|
+
if (property) {
|
|
25
|
+
edits.push(removeFromList(compilerOptions.properties, property, source));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// `target` is reported, not rewritten: removing it silently falls back to the compiler default
|
|
29
|
+
// (`es5` for `tsc`), and choosing a replacement means guessing which era the project targets.
|
|
30
|
+
const target = propertyValue(compilerOptions, 'target');
|
|
31
|
+
if (target && ts.isStringLiteral(target) && target.text.toLowerCase() === 'esnext') {
|
|
32
|
+
unresolved.push(`${fileName}: 'target' is 'esnext', the one target that emits decorator syntax untransformed. ` +
|
|
33
|
+
'Change it to any dated target, such as `es2024`.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// A value the file inherits cannot be edited here, and silence would read as "there was none".
|
|
37
|
+
if (propertyValue(root, 'extends') && !edits.length) {
|
|
38
|
+
unresolved.push(`${fileName}: extends another config, so check the base for 'experimentalDecorators', ` +
|
|
39
|
+
"'emitDecoratorMetadata' and 'target'.");
|
|
40
|
+
}
|
|
41
|
+
return { text: applyEdits(text, edits), changed: edits.length > 0, unresolved };
|
|
42
|
+
}
|
|
43
|
+
function propertyAssignment(object, name) {
|
|
44
|
+
return object.properties.find((property) => ts.isPropertyAssignment(property) && ts.isStringLiteral(property.name) && property.name.text === name);
|
|
45
|
+
}
|
|
46
|
+
function propertyValue(object, name) {
|
|
47
|
+
return propertyAssignment(object, name)?.initializer;
|
|
48
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uql-codemod",
|
|
3
|
+
"description": "Migrates uql-orm entities from legacy decorators to the TC39 standard spec",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"version": "0.23.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"uql-codemod": "./dist/bin.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/index.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "rimraf dist .tsconfig.tsbuildinfo && tsc -b tsconfig.build.json",
|
|
25
|
+
"clean": "rimraf dist .tsconfig.tsbuildinfo"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"typescript": "^6.0.3"
|
|
29
|
+
},
|
|
30
|
+
"author": "Roger Padilla",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/rogerpadilla/uql.git",
|
|
34
|
+
"directory": "packages/uql-codemod"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"uql",
|
|
38
|
+
"codemod",
|
|
39
|
+
"decorators",
|
|
40
|
+
"migration"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"gitHead": "3bfca5950ac564b8503fb2140726501b01825b9d"
|
|
46
|
+
}
|