chore: Set up repository for TypeScript 7 migration (#33890)

This commit is contained in:
Matsu
2026-07-09 13:21:16 +03:00
committed by GitHub
parent 1fd337a734
commit cbf2d2027b
65 changed files with 1250 additions and 95 deletions
@@ -10,7 +10,9 @@
"n8n-node-dev",
"n8n-nodes-base",
"n8n-playwright",
"n8n-workflow"
"n8n-workflow",
"typescript",
"@typescript/*"
]
}
}
+3
View File
@@ -72,6 +72,9 @@ coverage-gaps.json
# Local regeneration cache for scripts/generate-lucide-icon-data.mjs (committed output: lucideIconData.ts)
scripts/.lucide-tags-cache.json
# TS 6 -> 7 migration benchmark output
scripts/typescript-migration/results/
packages/cli/src/commands/export/outputs
*.bak
.data
+1 -2
View File
@@ -89,7 +89,7 @@
"tsc-alias": "^1.8.10",
"tsc-watch": "^6.2.0",
"turbo": "2.9.15",
"typescript": "*",
"typescript": "6.0.2",
"yaml": "catalog:",
"zx": "^8.8.5"
},
@@ -121,7 +121,6 @@
"tar-fs": "2.1.4",
"tslib": "^2.6.2",
"tsconfig-paths": "^4.2.0",
"typescript": "catalog:",
"vue-tsc": "catalog:frontend",
"gaxios": ">=7.1.1",
"google-gax": "^4.3.7",
+1
View File
@@ -164,6 +164,7 @@
"nock": "catalog:",
"pg": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
+1
View File
@@ -37,6 +37,7 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
+1
View File
@@ -112,6 +112,7 @@
"@types/proxy-from-env": "catalog:",
"fast-glob": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"axios": "catalog:",
"nock": "catalog:"
},
@@ -98,6 +98,7 @@
"cli-table3": "^0.6.3",
"madge": "^8.0.0",
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+1
View File
@@ -27,6 +27,7 @@
"@n8n/config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
@@ -44,6 +44,7 @@
"@types/yargs-parser": "21.0.0",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"zod": "catalog:"
@@ -83,6 +83,7 @@
"@types/qs": "catalog:",
"@vitest/coverage-v8": "catalog:",
"nock": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
@@ -36,7 +36,8 @@
"vitest-mock-extended": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
"@n8n/typescript-config": "workspace:*",
"typescript": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}
+2 -1
View File
@@ -44,7 +44,8 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/convict": "^6.1.1",
"@types/k6": "^0.52.0"
"@types/k6": "^0.52.0",
"typescript": "catalog:"
},
"bin": {
"n8n-benchmark": "./bin/n8n-benchmark"
+1
View File
@@ -26,6 +26,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
+1
View File
@@ -81,6 +81,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
+1
View File
@@ -30,6 +30,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
@@ -46,6 +46,7 @@
"@codemirror/buildhelper": "^0.1.5",
"@lezer/generator": "catalog:",
"@n8n/typescript-config": "workspace:*",
"typescript": "catalog:typescript",
"@rollup/plugin-node-resolve": "^9.0.0",
"@n8n/vitest-config": "workspace:*",
"ist": "1.1.7",
@@ -50,6 +50,7 @@
"devDependencies": {
"@lezer/generator": "catalog:",
"@n8n/typescript-config": "workspace:*",
"typescript": "catalog:typescript",
"@n8n/vitest-config": "workspace:*",
"vitest": "catalog:"
},
@@ -1,5 +1,5 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
@@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "vitest/globals"]
+3 -2
View File
@@ -41,13 +41,14 @@
},
"dependencies": {
"@codemirror/language": "catalog:",
"@lezer/generator": "catalog:",
"@lezer/highlight": "catalog:",
"@lezer/lr": "catalog:",
"@lezer/generator": "catalog:"
"@lezer/lr": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"typescript": "catalog:typescript",
"vite": "catalog:",
"vitest": "catalog:"
}
@@ -1,5 +1,5 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "vitest/globals", "vite/client"]
+1
View File
@@ -61,6 +61,7 @@
"@types/node": "catalog:",
"@types/yargs-parser": "21.0.0",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
+1
View File
@@ -32,6 +32,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+2 -1
View File
@@ -21,7 +21,8 @@
"dist/**/*"
],
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
"@n8n/typescript-config": "workspace:*",
"typescript": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}
+1
View File
@@ -37,6 +37,7 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-websocket-mock": "^0.5.0"
},
+1
View File
@@ -27,6 +27,7 @@
"@n8n/vitest-config": "workspace:*",
"@types/express": "catalog:",
"@types/lodash": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"zod": "catalog:"
},
+1
View File
@@ -28,6 +28,7 @@
"devDependencies": {
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"@n8n/typescript-config": "workspace:*"
+1
View File
@@ -35,6 +35,7 @@
"@types/express": "catalog:",
"@types/supertest": "^6.0.3",
"supertest": "^7.1.1",
"typescript": "catalog:",
"vitest": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
+2 -1
View File
@@ -23,7 +23,8 @@
],
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@sentry/node": "catalog:sentry"
"@sentry/node": "catalog:sentry",
"typescript": "catalog:"
},
"dependencies": {
"callsites": "catalog:"
+1
View File
@@ -51,6 +51,7 @@
"@vue/tsconfig": "catalog:frontend",
"prettier": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"rimraf": "catalog:",
"vite": "catalog:",
"vue": "catalog:frontend",
+1
View File
@@ -34,6 +34,7 @@
"@types/imap": "^0.8.40",
"@types/quoted-printable": "^1.0.2",
"@types/utf8": "^3.0.3",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+1
View File
@@ -96,6 +96,7 @@
"@types/turndown": "catalog:",
"@vitest/coverage-v8": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
@@ -69,6 +69,7 @@
"zod": "catalog:",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
}
+1
View File
@@ -37,6 +37,7 @@
"concurrently": "^8.2.0",
"electron-builder": "^26.9.0",
"rimraf": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
@@ -29,6 +29,7 @@
"@vitejs/plugin-vue": "catalog:frontend",
"@vue/test-utils": "catalog:frontend",
"unplugin-icons": "catalog:frontend",
"typescript": "catalog:",
"vite": "catalog:",
"vite-svg-loader": "catalog:frontend",
"vitest": "catalog:"
+1
View File
@@ -46,6 +46,7 @@
"@types/ws": "catalog:",
"@types/yargs-parser": "21.0.0",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"tsx": "catalog:",
"vitest": "catalog:"
},
@@ -228,6 +228,7 @@
"@vitest/coverage-v8": "catalog:",
"eslint-plugin-n8n-nodes-base": "catalog:",
"fast-glob": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
+1
View File
@@ -29,6 +29,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+1
View File
@@ -40,6 +40,7 @@
"@n8n/vitest-config": "workspace:*",
"@types/luxon": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
+1
View File
@@ -29,6 +29,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
+1
View File
@@ -59,6 +59,7 @@
"@types/luxon": "catalog:",
"@types/ws": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+1 -1
View File
@@ -44,7 +44,7 @@
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"typescript": "^5.0.0"
"typescript": "catalog:"
},
"dependencies": {
"ast-types": "^0.16.1",
+1 -1
View File
@@ -84,7 +84,7 @@
"source-map-support": "^0.5.21",
"sqlite3": "5.1.7",
"ts-node": "^10.9.1",
"typescript": "^5.3.3"
"typescript": "catalog:"
},
"peerDependencies": {
"pg": "^8.17.0",
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"types": ["node"],
"noUnusedLocals": false,
"noUnusedParameters": false,
"declaration": true,
"tsBuildInfoFile": "${configDir}/dist/build.tsbuildinfo"
},
"tsc-alias": {
"replacers": {
"base-url": {
"enabled": false
}
}
}
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"noUncheckedSideEffectImports": false,
"strict": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "es2021",
"lib": ["es2021", "es2022.error", "es2022.object", "dom"],
"removeComments": true,
"useUnknownInCatchVariables": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": false,
"sourceMap": true,
"skipLibCheck": true,
"tsBuildInfoFile": "${configDir}/dist/typecheck.tsbuildinfo"
}
}
+1
View File
@@ -9,6 +9,7 @@
"dependencies": {},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
+1
View File
@@ -94,6 +94,7 @@
"@types/lodash": "catalog:",
"@vitest/coverage-v8": "catalog:",
"adm-zip": "^0.5.16",
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
+1
View File
@@ -117,6 +117,7 @@
"ts-essentials": "^7.0.3",
"tsconfig-paths": "^4.2.0",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+1
View File
@@ -48,6 +48,7 @@
"axios": "catalog:",
"nock": "catalog:",
"reflect-metadata": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
@@ -54,6 +54,7 @@
"@vue/tsconfig": "catalog:frontend",
"stylelint": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"rimraf": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
+1
View File
@@ -66,6 +66,7 @@
"@vitest/coverage-v8": "catalog:",
"@vue/test-utils": "catalog:frontend",
"unplugin-icons": "^0.19.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vitest": "catalog:",
@@ -52,6 +52,7 @@
"storybook-addon-vue-mdx": "3.0.0",
"stylelint": "catalog:",
"tailwindcss": "^3.4.3",
"typescript": "catalog:",
"unplugin-icons": "catalog:frontend",
"vite": "catalog:",
"vite-svg-loader": "catalog:frontend",
+1
View File
@@ -42,6 +42,7 @@
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/inquirer": "^7.0.1",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
+1
View File
@@ -919,6 +919,7 @@
"n8n-containers": "workspace:*",
"n8n-core": "workspace:*",
"reflect-metadata": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
+2 -1
View File
@@ -15,7 +15,8 @@
"@codspeed/vitest-plugin": "^5.2.0",
"@n8n/expression-runtime": "workspace:*",
"vitest": "catalog:",
"n8n-workflow": "workspace:*"
"n8n-workflow": "workspace:*",
"typescript": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}
+1
View File
@@ -83,6 +83,7 @@
"otplib": "^12.0.1",
"ts-morph": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"zod": "catalog:",
"vitest": "catalog:"
},
+1
View File
@@ -61,6 +61,7 @@
"@types/ssh2": "catalog:",
"@types/xml2js": "catalog:",
"fast-check": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
+488 -79
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -214,6 +214,8 @@ catalog:
catalogMode: strict
catalogs:
typescript:
typescript: 7.0.2
e2e:
'@currents/playwright': ^2.0.0
'@playwright/cli': 0.1.0
@@ -267,3 +269,5 @@ minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- '@n8n/*'
- '@n8n_io/*'
- 'typescript'
- '@typescript/*'
+79
View File
@@ -0,0 +1,79 @@
# TypeScript 6 → 7 migration tooling
Support scripts for migrating the monorepo from TypeScript `6.0.2` to the
TypeScript 7 line, and for moving legacy packages off `moduleResolution: node`
onto **NodeNext**. Migration is done **per package**.
## Scripts
### `benchmark.mjs` — build/typecheck timing
Times a single package's `build` and `typecheck` scripts and records the result
to a per-package JSON file. Run it *before* switching compilers, switch, then
run it again — the second run appends to the same file and prints a delta table.
```bash
# before the switch
node scripts/typescript-migration/benchmark.mjs @n8n/config --label=before
# … swap in the TS 7 compiler …
# after — appends to the same file and prints the before/after delta
node scripts/typescript-migration/benchmark.mjs @n8n/config --label=after
```
**Options**
| Flag | Default | Meaning |
| ---------------- | -------------------- | ------------------------------------------------------------- |
| `<package>` | — | Workspace name (`@n8n/config`) or a package dir. |
| `--label=<name>` | `ts-<tsc version>` | Storage key. Different compilers auto-separate by version. |
| `--runs=<n>` | `3` | Repetitions per task; reports min/median/mean. |
| `--tasks=a,b` | `typecheck,build` | Subset to run. Missing scripts are skipped with a warning. |
| `--no-prepare` | prepare on | Skip the untimed dependency prebuild. |
**How it isolates the package** — dependencies are prebuilt once (untimed) via
`turbo run build --filter=<pkg>^...`, then each timed run deletes the package's
`dist/` + `*.tsbuildinfo` (cold compile) and runs the package script directly
with `pnpm --filter <pkg> run <task>`, bypassing turbo's cache. So the numbers
reflect only that package's own `tsc` (+ `tsc-alias`) time.
Results land in `results/<pkg>.json` (gitignored). The label is compiler-
agnostic: this script never installs or selects TypeScript — wire that up
separately and just re-run with a new `--label`.
### `add-import-extensions.mjs` — NodeNext import codemod
Adds explicit `.js` / `.json` / `/index.js` extensions to **relative** import,
re-export, and dynamic-`import()` specifiers, which NodeNext (and ESM) require.
```bash
# dry-run: prints every rewrite it would make
node scripts/typescript-migration/add-import-extensions.mjs packages/workflow
# apply
node scripts/typescript-migration/add-import-extensions.mjs packages/workflow --write
```
- Uses `ts-morph` (already a catalog dep) and the package's `tsconfig.json` to
find source files.
- **Skips** bare/alias specifiers (`@/…`, `@utils/…`, package names) — alias
tails get their extension from `tsc-alias`'s `resolveFullPaths` at build time.
- **Skips** specifiers that already carry a known extension.
- Reports any relative specifier it can't resolve on disk for manual review;
it does not guess.
## Per-package migration loop
1. **Baseline:** `benchmark.mjs <pkg> --label=before`.
2. **Config:** point the package's tsconfig at NodeNext — extend
`@n8n/typescript-config/modern`, or set `module: NodeNext` +
`moduleResolution: NodeNext` locally.
3. **Codemod:** run `add-import-extensions.mjs <pkg>` (review), then `--write`.
4. **Aliases:** if the package keeps path aliases, enable `tsc-alias`
`resolveFullPaths` so alias tails also get extensions in emit.
5. **Verify:** `pnpm --filter <pkg> run build && pnpm --filter <pkg> run typecheck && pnpm --filter <pkg> test`.
6. **After:** `benchmark.mjs <pkg> --label=after` → confirm the delta table.
Migrate leaf/low-dependency packages first (`workflow`, `@n8n/config`,
`@n8n/di`) so downstream typechecks stay green, then work up toward `cli`.
+42
View File
@@ -0,0 +1,42 @@
# TypeScript 6 → 7 migration benchmarks
Generated 2026-07-09T08:18:07.078Z from `scripts/typescript-migration/results/`.
| Package | typecheck Δ | build Δ |
| --- | --- | --- |
| `@n8n/codemirror-lang` | -36.3% | -31.4% |
| `@n8n/codemirror-lang-html` | — | -17.0% |
| `@n8n/codemirror-lang-sql` | -42.7% | -39.2% |
```
=== @n8n/codemirror-lang — median times (Δ vs "before") ===
typecheck:
before 699ms
after 445ms -254ms (-36.3%)
build:
before 650ms
after 446ms -204ms (-31.4%)
```
```
=== @n8n/codemirror-lang-html — median times (Δ vs "before") ===
build:
before 1.09s
after 905ms -186ms (-17.0%)
```
```
=== @n8n/codemirror-lang-sql — median times (Δ vs "before") ===
typecheck:
before 785ms
after 450ms -335ms (-42.7%)
build:
before 720ms
after 438ms -282ms (-39.2%)
```
@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Codemod: add explicit .js/.json extensions to relative import specifiers so a
// package can move from `moduleResolution: node` to NodeNext.
//
// NodeNext (and ESM) require relative specifiers to carry their runtime
// extension. TypeScript's convention is to import a `.ts` source as `.js`, and
// a directory as `<dir>/index.js`. This rewrites:
// import x from './foo' -> './foo.js'
// export * from './bar' -> './bar.js'
// import('./baz') -> './baz.js'
// import cfg from './data' -> './data.json' (when data.json exists)
// import x from './widgets' -> './widgets/index.js'
//
// It SKIPS bare/alias specifiers (`@/…`, `@utils/…`, package names) — those are
// handled by tsc-alias's `resolveFullPaths` at build time — and anything that
// already has a known extension.
//
// Dry-run by default; pass --write to apply.
//
// Usage:
// node scripts/typescript-migration/add-import-extensions.mjs <package> [--write]
import { createRequire } from 'node:module';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..', '..');
const KNOWN_EXTENSIONS = ['.js', '.mjs', '.cjs', '.json', '.node'];
function fail(message) {
console.error(`\n${message}\n`);
process.exit(1);
}
// ts-morph is a catalog dep of several packages, not hoisted to the root, so
// resolve it from a package that depends on it.
async function loadTsMorph() {
const candidates = [
'packages/testing/janitor',
'packages/testing/code-health',
'packages/@n8n/node-cli',
];
for (const rel of candidates) {
const anchor = join(REPO_ROOT, rel, 'package.json');
if (!existsSync(anchor)) continue;
try {
const require = createRequire(anchor);
const entry = require.resolve('ts-morph');
return await import(pathToFileURL(entry).href);
} catch {
// try next anchor
}
}
fail('Could not resolve "ts-morph". Run `pnpm install` first.');
}
function parseArgs(argv) {
let write = false;
const positionals = [];
for (const arg of argv) {
if (arg === '--write') write = true;
else if (arg.startsWith('--')) fail(`Unknown option: ${arg}`);
else positionals.push(arg);
}
if (positionals.length !== 1) fail('Expected exactly one <package> argument.');
return { package: positionals[0], write };
}
function findTsConfig(pkgArg) {
const dir = resolve(REPO_ROOT, pkgArg);
if (existsSync(join(dir, 'tsconfig.json'))) return join(dir, 'tsconfig.json');
fail(`No tsconfig.json found at "${dir}". Pass a package directory (e.g. packages/workflow).`);
}
function isRelative(spec) {
return spec.startsWith('./') || spec.startsWith('../');
}
function hasKnownExtension(spec) {
return KNOWN_EXTENSIONS.some((ext) => spec.endsWith(ext));
}
// Resolve a relative specifier to the extension it should carry, or null if it
// can't be resolved on disk (caller reports it for manual review).
function resolveExtension(fromFile, spec) {
const base = resolve(dirname(fromFile), spec);
for (const ext of ['.ts', '.tsx', '.mts', '.cts']) {
if (existsSync(base + ext)) return '.js';
}
if (existsSync(base + '.json')) return '.json';
if (existsSync(base + '.js')) return '.js';
for (const idx of ['index.ts', 'index.tsx', 'index.mts', 'index.cts', 'index.js']) {
if (existsSync(join(base, idx))) return '/index.js';
}
return null;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const tsConfigFilePath = findTsConfig(opts.package);
const { Project, SyntaxKind } = await loadTsMorph();
console.log(`\nCodemod: add import extensions`);
console.log(` tsconfig: ${tsConfigFilePath}`);
console.log(` mode: ${opts.write ? 'WRITE' : 'dry-run (pass --write to apply)'}\n`);
// Only rewrite the target package's own hand-written sources. The project
// (via references) also loads dist output and other packages' declaration
// files — those aren't ours to touch.
const pkgDir = dirname(tsConfigFilePath);
const project = new Project({ tsConfigFilePath });
const sourceFiles = project.getSourceFiles().filter((sf) => {
const p = sf.getFilePath();
return (
p.startsWith(`${pkgDir}/`) &&
!p.includes('/node_modules/') &&
!p.includes('/dist/') &&
!sf.isDeclarationFile()
);
});
let scanned = 0;
let rewritten = 0;
const unresolved = [];
for (const sf of sourceFiles) {
scanned++;
const filePath = sf.getFilePath();
const relFile = filePath.replace(`${REPO_ROOT}/`, '');
let changedInFile = false;
// Collect (node, currentSpec, setter) for static import/export + dynamic import.
const targets = [];
for (const decl of [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]) {
const spec = decl.getModuleSpecifierValue();
if (spec) targets.push({ spec, set: (v) => decl.setModuleSpecifier(v) });
}
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
const [arg] = call.getArguments();
if (arg && arg.getKind() === SyntaxKind.StringLiteral) {
targets.push({ spec: arg.getLiteralText(), set: (v) => arg.setLiteralValue(v) });
}
}
for (const { spec, set } of targets) {
if (!isRelative(spec) || hasKnownExtension(spec)) continue;
const ext = resolveExtension(filePath, spec);
if (!ext) {
unresolved.push(`${relFile}: '${spec}'`);
continue;
}
set(ext.startsWith('/') ? spec + ext : spec + ext);
rewritten++;
changedInFile = true;
if (!opts.write) console.log(` ${relFile}: '${spec}' -> '${spec + ext}'`);
}
if (changedInFile && opts.write) sf.saveSync();
}
console.log(`\n${'='.repeat(48)}`);
console.log(` files scanned: ${scanned}`);
console.log(` specifiers ${opts.write ? 'rewritten' : 'to rewrite'}: ${rewritten}`);
console.log(` unresolved: ${unresolved.length}`);
if (unresolved.length) {
console.log(`\n⚠ Unresolved relative specifiers (review manually):`);
for (const u of unresolved) console.log(` ${u}`);
}
if (!opts.write && rewritten > 0) console.log(`\nRe-run with --write to apply.`);
console.log('');
}
main();
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
// Per-package build/typecheck timing for the TS 6 -> 7 migration.
//
// Run it before switching compilers (writes a results file), switch the
// compiler, then run it again — the second run appends to the same file and
// prints a before/after delta table. Migration is per-package, so this always
// targets a single workspace package.
//
// Usage:
// node scripts/typescript-migration/benchmark.mjs <package> [options]
//
// Options:
// --label=<name> Key the run is stored under. Defaults to the detected
// TypeScript version (e.g. "ts-6.0.2"), so before/after
// with different compilers auto-separate. Re-running a
// label overwrites it.
// --runs=<n> Repetitions per task (default 3). Reports min/median/mean.
// --tasks=a,b Subset of "typecheck,build" (default both). Missing
// scripts are skipped with a warning.
// --no-prepare Skip the untimed dependency prebuild.
//
// Example:
// node scripts/typescript-migration/benchmark.mjs @n8n/config --label=before
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..', '..');
const RESULTS_DIR = join(SCRIPT_DIR, 'results');
const DEFAULT_TASKS = ['typecheck', 'build'];
function parseArgs(argv) {
const opts = { runs: 3, tasks: DEFAULT_TASKS, prepare: true, label: undefined };
const positionals = [];
for (const arg of argv) {
if (arg === '--no-prepare') opts.prepare = false;
else if (arg.startsWith('--label=')) opts.label = arg.slice('--label='.length);
else if (arg.startsWith('--runs=')) opts.runs = Number(arg.slice('--runs='.length));
else if (arg.startsWith('--tasks=')) opts.tasks = arg.slice('--tasks='.length).split(',').map((t) => t.trim()).filter(Boolean);
else if (arg.startsWith('--')) fail(`Unknown option: ${arg}`);
else positionals.push(arg);
}
if (positionals.length !== 1) fail('Expected exactly one <package> argument.');
if (!Number.isInteger(opts.runs) || opts.runs < 1) fail('--runs must be a positive integer.');
opts.package = positionals[0];
return opts;
}
function fail(message) {
console.error(`\n${message}\n`);
process.exit(1);
}
// Resolve a workspace package name or directory to { name, dir, scripts }.
function resolvePackage(nameOrDir) {
// A directory path was given directly.
const asDir = resolve(REPO_ROOT, nameOrDir);
if (existsSync(join(asDir, 'package.json'))) {
const pkg = JSON.parse(readFileSync(join(asDir, 'package.json'), 'utf8'));
return { name: pkg.name, dir: asDir, scripts: pkg.scripts ?? {} };
}
// Otherwise treat it as a workspace package name and ask pnpm where it lives.
let out;
try {
out = execFileSync('pnpm', ['--filter', nameOrDir, 'exec', 'node', '-p', 'process.cwd()'], {
cwd: REPO_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch {
fail(`Could not resolve package "${nameOrDir}" — not a directory and pnpm --filter found no match.`);
}
const dir = out.trim().split('\n').filter(Boolean).pop();
if (!dir || !existsSync(join(dir, 'package.json'))) fail(`Resolved an invalid directory for "${nameOrDir}".`);
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
return { name: pkg.name, dir, scripts: pkg.scripts ?? {} };
}
function detectTsVersion(pkgName) {
const res = spawnSync('pnpm', ['--filter', pkgName, 'exec', 'tsc', '--version'], {
cwd: REPO_ROOT,
encoding: 'utf8',
});
const match = /Version\s+([\d.]+)/.exec(res.stdout ?? '');
return match ? match[1] : 'unknown';
}
// Wipe compiled output + incremental caches so every timed run is cold.
function cleanBuildArtifacts(pkgDir) {
rmSync(join(pkgDir, 'dist'), { recursive: true, force: true });
rmSync(join(pkgDir, 'tsconfig.tsbuildinfo'), { force: true });
}
function timeTask(pkgName, pkgDir, task) {
cleanBuildArtifacts(pkgDir);
const start = process.hrtime.bigint();
const res = spawnSync('pnpm', ['--filter', pkgName, 'run', task], {
cwd: REPO_ROOT,
stdio: 'inherit',
});
const ms = Number(process.hrtime.bigint() - start) / 1e6;
if (res.status !== 0) fail(`Task "${task}" failed (exit ${res.status}); aborting so timings aren't polluted.`);
return ms;
}
function summarize(runsMs) {
const sorted = [...runsMs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
const mean = runsMs.reduce((a, b) => a + b, 0) / runsMs.length;
return {
runsMs: runsMs.map((n) => Math.round(n)),
min: Math.round(sorted[0]),
median: Math.round(median),
mean: Math.round(mean),
};
}
function fmtMs(ms) {
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
}
// Baseline-first ordering: "before" is always the baseline and "after" always
// last regardless of recording order; anything else falls between by timestamp.
function orderLabels(runs) {
const rank = (l) => (/^before$/i.test(l) ? -1 : /^after$/i.test(l) ? 1 : 0);
return Object.keys(runs).sort((a, b) => {
if (rank(a) !== rank(b)) return rank(a) - rank(b);
return (runs[a].timestamp ?? '').localeCompare(runs[b].timestamp ?? '');
});
}
// Print a comparison table across every stored label, deltas vs the baseline.
function printComparison(data) {
const labels = orderLabels(data.runs);
if (labels.length < 2) {
console.log(`\nStored 1 run ("${labels[0]}"). Run again with a second --label to see a delta.\n`);
return;
}
const base = labels[0];
console.log(`\n=== ${data.package} — median times (Δ vs "${base}") ===`);
const tasks = new Set();
for (const l of labels) for (const t of Object.keys(data.runs[l].tasks)) tasks.add(t);
for (const task of tasks) {
console.log(`\n${task}:`);
const baseMedian = data.runs[base].tasks[task]?.median;
for (const label of labels) {
const stat = data.runs[label].tasks[task];
if (!stat) {
console.log(` ${label.padEnd(16)} (not measured)`);
continue;
}
let delta = '';
if (label !== base && baseMedian != null) {
const diff = stat.median - baseMedian;
const pct = ((diff / baseMedian) * 100).toFixed(1);
const sign = diff <= 0 ? '' : '+';
delta = ` ${sign}${fmtMs(diff)} (${sign}${pct}%)`;
}
console.log(` ${label.padEnd(16)} ${fmtMs(stat.median).padStart(9)}${delta}`);
}
}
console.log('');
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const pkg = resolvePackage(opts.package);
const tsVersion = detectTsVersion(pkg.name);
const label = opts.label ?? `ts-${tsVersion}`;
const tasks = opts.tasks.filter((task) => {
if (pkg.scripts[task]) return true;
console.warn(`⚠ Package "${pkg.name}" has no "${task}" script — skipping.`);
return false;
});
if (tasks.length === 0) fail('No runnable tasks for this package.');
console.log(`\nBenchmarking ${pkg.name}`);
console.log(` label: ${label}`);
console.log(` tsc: ${tsVersion}`);
console.log(` node: ${process.version}`);
console.log(` runs/task: ${opts.runs}`);
console.log(` tasks: ${tasks.join(', ')}\n`);
if (opts.prepare) {
console.log('Prebuilding dependencies (untimed)…');
const prep = spawnSync('pnpm', ['build', `--filter=${pkg.name}^...`], {
cwd: REPO_ROOT,
stdio: 'inherit',
});
if (prep.status !== 0) fail('Dependency prebuild failed. Fix the build or pass --no-prepare.');
}
const taskStats = {};
for (const task of tasks) {
const runsMs = [];
for (let i = 0; i < opts.runs; i++) {
console.log(`\n${task} — run ${i + 1}/${opts.runs}`);
runsMs.push(timeTask(pkg.name, pkg.dir, task));
}
taskStats[task] = summarize(runsMs);
console.log(`${task}: min ${fmtMs(taskStats[task].min)}, median ${fmtMs(taskStats[task].median)}, mean ${fmtMs(taskStats[task].mean)}`);
}
mkdirSync(RESULTS_DIR, { recursive: true });
const resultsFile = join(RESULTS_DIR, `${pkg.name.replace(/[@/]/g, '_')}.json`);
const data = existsSync(resultsFile)
? JSON.parse(readFileSync(resultsFile, 'utf8'))
: { package: pkg.name, runs: {} };
data.runs[label] = {
label,
tsVersion,
node: process.version,
timestamp: new Date().toISOString(),
tasks: taskStats,
};
writeFileSync(resultsFile, `${JSON.stringify(data, null, 2)}\n`);
console.log(`\n✔ Wrote ${resultsFile}`);
printComparison(data);
}
main();
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
// Roll every results/<pkg>.json into a single tracked Markdown report, so
// per-package before/after numbers are reviewable as PRs land.
//
// Usage:
// node scripts/typescript-migration/summarize.mjs [--out=<file>]
//
// Default output: scripts/typescript-migration/SUMMARY.md (committed — the
// results/ JSON is gitignored, this rollup is not).
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const RESULTS_DIR = join(SCRIPT_DIR, 'results');
function parseArgs(argv) {
let out = join(SCRIPT_DIR, 'SUMMARY.md');
for (const arg of argv) {
if (arg.startsWith('--out=')) out = resolve(process.cwd(), arg.slice('--out='.length));
else {
console.error(`\n✖ Unknown option: ${arg}\n`);
process.exit(1);
}
}
return { out };
}
function fmtMs(ms) {
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
}
// Baseline-first ordering: a label named "before" is always the baseline and
// "after" always last, regardless of the order they were recorded in; anything
// else falls between them by timestamp. Keeps deltas pointing the right way.
function orderLabels(runs) {
const rank = (l) => (/^before$/i.test(l) ? -1 : /^after$/i.test(l) ? 1 : 0);
return Object.keys(runs).sort((a, b) => {
if (rank(a) !== rank(b)) return rank(a) - rank(b);
return (runs[a].timestamp ?? '').localeCompare(runs[b].timestamp ?? '');
});
}
// Render one package's stored runs as the Δ-vs-baseline block.
function renderBlock(data) {
const labels = orderLabels(data.runs);
const base = labels[0];
const lines = [`=== ${data.package} — median times (Δ vs "${base}") ===`];
const tasks = new Set();
for (const l of labels) for (const t of Object.keys(data.runs[l].tasks)) tasks.add(t);
for (const task of tasks) {
lines.push('', `${task}:`);
const baseMedian = data.runs[base].tasks[task]?.median;
for (const label of labels) {
const stat = data.runs[label].tasks[task];
if (!stat) {
lines.push(` ${label.padEnd(16)} (not measured)`);
continue;
}
let delta = '';
if (label !== base && baseMedian != null) {
const diff = stat.median - baseMedian;
const pct = ((diff / baseMedian) * 100).toFixed(1);
const sign = diff <= 0 ? '' : '+';
delta = ` ${sign}${fmtMs(diff)} (${sign}${pct}%)`;
}
lines.push(` ${label.padEnd(16)} ${fmtMs(stat.median).padStart(9)}${delta}`);
}
}
return lines.join('\n');
}
// One "before → after" delta per task for the overview table (first vs last label).
function overviewDelta(data, task) {
const labels = orderLabels(data.runs);
const first = data.runs[labels[0]].tasks[task]?.median;
const last = data.runs[labels[labels.length - 1]].tasks[task]?.median;
if (labels.length < 2 || first == null || last == null) return '—';
const pct = (((last - first) / first) * 100).toFixed(1);
return `${last - first <= 0 ? '' : '+'}${pct}%`;
}
function main() {
const { out } = parseArgs(process.argv.slice(2));
if (!existsSync(RESULTS_DIR)) {
console.error(`\n✖ No results directory at ${RESULTS_DIR}. Run benchmark.mjs first.\n`);
process.exit(1);
}
const files = readdirSync(RESULTS_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(readFileSync(join(RESULTS_DIR, f), 'utf8')))
.sort((a, b) => a.package.localeCompare(b.package));
if (files.length === 0) {
console.error('\n✖ No result files found. Run benchmark.mjs first.\n');
process.exit(1);
}
const md = [];
md.push('# TypeScript 6 → 7 migration benchmarks', '');
md.push(`Generated ${new Date().toISOString()} from \`scripts/typescript-migration/results/\`.`, '');
// Overview table: median Δ (first → last label) per package.
md.push('| Package | typecheck Δ | build Δ |', '| --- | --- | --- |');
for (const data of files) {
md.push(`| \`${data.package}\` | ${overviewDelta(data, 'typecheck')} | ${overviewDelta(data, 'build')} |`);
}
md.push('');
// Per-package detail blocks in the benchmark's own format.
for (const data of files) {
md.push('```', renderBlock(data), '```', '');
}
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, `${md.join('\n')}\n`);
console.log(`✔ Wrote ${out} (${files.length} package${files.length === 1 ? '' : 's'})`);
}
main();