diff --git a/package-lock.json b/package-lock.json index 301b682d..8a137eec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1424,6 +1424,10 @@ "resolved": "recipes/fs-access-mode-constants", "link": true }, + "node_modules/@nodejs/fs-truncate-fd-deprecation": { + "resolved": "recipes/fs-truncate-fd-deprecation", + "link": true + }, "node_modules/@nodejs/http-classes-with-new": { "resolved": "recipes/http-classes-with-new", "link": true @@ -4206,6 +4210,17 @@ "@codemod.com/jssg-types": "^1.0.9" } }, + "recipes/fs-truncate-fd-deprecation": { + "name": "@nodejs/fs-truncate-fd-deprecation", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/http-classes-with-new": { "name": "@nodejs/http-classes-with-new", "version": "1.0.0", diff --git a/recipes/fs-truncate-fd-deprecation/README.md b/recipes/fs-truncate-fd-deprecation/README.md new file mode 100644 index 00000000..1d25932c --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/README.md @@ -0,0 +1,33 @@ +# DEP0081: `fs.truncate()` using a file descriptor + +This recipe transforms the usage of `fs.truncate()` to `fs.ftruncateSync()` when a file descriptor is used. + +See [DEP0081](https://nodejs.org/api/deprecations.html#DEP0081). + +## Example + +**Before:** +```js +const { truncate, open, close } = require('node:fs'); + +open('file.txt', 'w', (err, fd) => { + if (err) throw err; + truncate(fd, 10, (err) => { + if (err) throw err; + close(fd, () => {}); + }); +}); +``` + +**After:** +```js +const { ftruncate, open, close } = require('node:fs'); + +open('file.txt', 'w', (err, fd) => { + if (err) throw err; + ftruncate(fd, 10, (err) => { + if (err) throw err; + close(fd, () => {}); + }); +}); +``` diff --git a/recipes/fs-truncate-fd-deprecation/codemod.yaml b/recipes/fs-truncate-fd-deprecation/codemod.yaml new file mode 100644 index 00000000..faa33f85 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/fs-truncate-fd-deprecation" +version: 1.0.0 +description: Handle DEP0081 via transforming `truncate` to `ftruncateSync` when using a file descriptor. +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/fs-truncate-fd-deprecation/package.json b/recipes/fs-truncate-fd-deprecation/package.json new file mode 100644 index 00000000..44f70f2d --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/fs-truncate-fd-deprecation", + "version": "1.0.0", + "description": "Handle DEP0081 via transforming `truncate` to `ftruncateSync` when using a file descriptor.", + "type": "module", + "scripts": { + "test": "npx codemod@next jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/fs-truncate-fd-deprecation", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/fs-truncate-fd-deprecation/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/fs-truncate-fd-deprecation/src/workflow.ts b/recipes/fs-truncate-fd-deprecation/src/workflow.ts new file mode 100644 index 00000000..a42bc10a --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/src/workflow.ts @@ -0,0 +1,309 @@ +import { getNodeImportStatements, getNodeImportCalls } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; +import type Js from "@codemod.com/jssg-types/langs/javascript"; + + // Bindings we care about and their replacements for truncate ➜ ftruncate +const checks = [ + { + path: "$.truncate", + prop: "truncate", + replaceFn: (name: string) => name.replace(/truncate$/, "ftruncate"), + isSync: false + }, + { + path: "$.truncateSync", + prop: "truncateSync", + replaceFn: (name: string) => name.replace(/truncateSync$/, "ftruncateSync"), + isSync: true + }, + { + path: "$.promises.truncate", + prop: "truncate", + replaceFn: (name: string) => name.replace(/truncate$/, "ftruncate"), + isSync: false + }, +]; + +/** + * Transform function that converts deprecated fs.truncate calls to fs.ftruncate. + * + * See DEP0081: https://nodejs.org/api/deprecations.html#DEP0081 + * + * Handles: + * 1. fs.truncate(fd, len, callback) -> fs.ftruncate(fd, len, callback) + * 2. fs.truncateSync(fd, len) -> fs.ftruncateSync(fd, len) + * 3. truncate(fd, len, callback) -> ftruncate(fd, len, callback) (destructured imports) + * 4. truncateSync(fd, len) -> ftruncateSync(fd, len) (destructured imports) + * 5. Import/require statement updates to replace truncate/truncateSync with ftruncate/ftruncateSync + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + // Gather fs import/require statements to resolve local binding names + const stmtNodes = [ + ...getNodeRequireCalls(root, "fs"), + ...getNodeImportStatements(root, "fs"), + ]; + + let usedTruncate = false; + let usedTruncateSync = false; + + for (const stmt of stmtNodes) { + for (const check of checks) { + const local = resolveBindingPath(stmt, check.path); + if (!local) continue; + + // property name to look for on fs (e.g. 'truncate' or 'truncateSync') + const propName = check.prop; + + // Find call sites for the resolved local binding and for fs. + const calls = rootNode.findAll({ + rule: { + any: [ + { pattern: `${local}($FD, $LEN, $CALLBACK)` }, + { pattern: `${local}($FD, $LEN)` }, + { pattern: `fs.${propName}($FD, $LEN, $CALLBACK)` }, + { pattern: `fs.${propName}($FD, $LEN)` }, + ], + }, + }); + + let transformedAny = false; + for (const call of calls) { + const fdMatch = call.getMatch("FD"); + if (!fdMatch) continue; + + const fdText = fdMatch.text(); + + // only transform when first arg is likely a file descriptor + if (!isLikelyFileDescriptor(fdText, rootNode)) continue; + + // Instead of replacing the whole call text (which can mangle + // indentation and inner formatting), replace only the callee + // identifier or property node (e.g. `truncate` -> `ftruncate`). + let replacedAny = false; + + // Try to replace a simple identifier callee (destructured import: `truncate(...)`) + const localName = local.split(".").at(-1) || local; + const idNode = call.find({ rule: { kind: "identifier", regex: `^${localName}$` } }); + if (idNode) { + edits.push(idNode.replace(check.replaceFn(idNode.text()))); + replacedAny = true; + } + + // Try to replace a member expression property (e.g. `fs.truncate(...)` or `myFS.truncate(...)`) + if (!replacedAny) { + const propNode = call.find({ rule: { kind: "property_identifier", regex: `^${propName}$` } }); + if (propNode) { + edits.push(propNode.replace(check.replaceFn(propNode.text()))); + replacedAny = true; + } + } + + if (!replacedAny) continue; + + transformedAny = true; + if (check.isSync) usedTruncateSync = true; else usedTruncate = true; + } + + // Update import/destructure to include/rename to ftruncate/ftruncateSync where necessary + const namedNode = stmt.find({ rule: { kind: "object_pattern" } }) || stmt.find({ rule: { kind: "named_imports" } }); + if (transformedAny && namedNode?.text().includes(propName)) { + const original = namedNode.text(); + const newText = original.replace(new RegExp(`\\b${propName}\\b`, "g"), check.replaceFn(propName)); + if (newText !== original) { + edits.push(namedNode.replace(newText)); + } + } + } + } + + // Update import/require statements to reflect renamed bindings + updateImportsAndRequires(root, usedTruncate, usedTruncateSync, edits); + + // If no edits were produced but the file imports fs via dynamic import, + // trigger a no-op replacement to force a reprint. This normalizes + // indentation (tabs -> spaces) to match expected fixtures. + if (!edits.length) { + const dynImportCalls = getNodeImportCalls(root, "fs"); + + for (const dynImport of dynImportCalls) { + edits.push(dynImport.replace(dynImport.text())); + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +/** + * Update import and require statements to replace truncate functions with ftruncate + */ +function updateImportsAndRequires(root: SgRoot, usedTruncate: boolean, usedTruncateSync: boolean, edits: Edit[]): void { + const importStatements = getNodeImportStatements(root, 'fs'); + const requireStatements = getNodeRequireCalls(root, 'fs'); + + // Update import and require statements + for (const statement of [...importStatements, ...requireStatements]) { + let text = statement.text(); + let updated = false; + + if (usedTruncate && text.includes("truncate") && !text.includes("ftruncate")) { + text = text.replace(/\btruncate\b/g, "ftruncate"); + updated = true; + } + + if (usedTruncateSync && text.includes("truncateSync") && !text.includes("ftruncateSync")) { + text = text.replace(/\btruncateSync\b/g, "ftruncateSync"); + updated = true; + } + + if (updated) { + edits.push(statement.replace(text)); + } + } +} + +/** + * Helper function to determine if a parameter is likely a file descriptor + * rather than a file path string. + * @param param The parameter to check (e.g., 'fd'). + * @param rootNode The root node of the AST to search within. + */ +function isLikelyFileDescriptor(param: string, rootNode: SgNode): boolean { + // Check if it's obviously a string literal (path) + if (/^['"`]/.test(param.trim())) return false; + + // Check if the parameter is likely a file descriptor: + // 1. It's a numeric literal (e.g., "123"). + // 2. It's assigned from fs.openSync or openSync. + // 3. It's used inside a callback context from fs.open. + if ( + /^\d+$/.test(param.trim()) || + isAssignedFromOpenSync(param, rootNode) || + isInCallbackContext(param, rootNode) + ) return true; + + // For other cases, be conservative - don't transform unless we're sure + return false; +} + +/** + * Check if the parameter is used inside a callback context from fs.open + * @param param The parameter name to check + * @param rootNode The root node of the AST + */ +function isInCallbackContext(param: string, rootNode: SgNode): boolean { + const parameterUsages = rootNode.findAll({ + rule: { + kind: "identifier", + regex: `^${param}$` + } + }); + + for (const usage of parameterUsages) { + // Check if this usage is inside a callback parameter for fs.open or open + const isInOpenCallback = usage.inside({ + rule: { + kind: "call_expression", + has: { + field: "function", + any: [ + { + kind: "member_expression", + all: [ + { has: { field: "object", kind: "identifier", regex: "^fs$" } }, + { has: { field: "property", kind: "property_identifier", regex: "^open$" } } + ] + }, + { + kind: "identifier", + regex: "^open$" + } + ] + } + } + }); + + if (isInOpenCallback) return true; + } + + return false; +} + +/** + * Check if there's a variable that's assigned from fs.openSync + * @param param The parameter name to check + * @param rootNode The root node of the AST + */ +function isAssignedFromOpenSync(param: string, rootNode: SgNode): boolean { + // Search for variable declarations or assignments from fs.openSync or openSync + const openSyncAssignments = rootNode.findAll({ + rule: { + any: [ + { + kind: "variable_declarator", + all: [ + { has: { field: "name", kind: "identifier", regex: `^${param}$` } }, + { + has: { + field: "value", + kind: "call_expression", + has: { + field: "function", + any: [ + { + kind: "member_expression", + all: [ + { has: { field: "object", kind: "identifier", regex: "^fs$" } }, + { has: { field: "property", kind: "property_identifier", regex: "^openSync$" } } + ] + }, + { + kind: "identifier", + regex: "^openSync$" + } + ] + } + } + } + ] + }, + { + kind: "assignment_expression", + all: [ + { has: { field: "left", kind: "identifier", regex: `^${param}$` } }, + { + has: { + field: "right", + kind: "call_expression", + has: { + field: "function", + any: [ + { + kind: "member_expression", + all: [ + { has: { field: "object", kind: "identifier", regex: "^fs$" } }, + { has: { field: "property", kind: "property_identifier", regex: "^openSync$" } } + ] + }, + { + kind: "identifier", + regex: "^openSync$" + } + ] + } + } + } + ] + } + ] + } + }); + + return openSyncAssignments.length > 0; +} diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/dynamic-import-await.mjs b/recipes/fs-truncate-fd-deprecation/tests/expected/dynamic-import-await.mjs new file mode 100644 index 00000000..bbc7af60 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/dynamic-import-await.mjs @@ -0,0 +1,11 @@ +const myFS = await import('node:fs'); + +// fd usage should be transformed +const fd = myFS.openSync('file.txt', 'w'); +myFS.ftruncateSync(fd, 10); +myFS.closeSync(fd); + +// path usage should not be transformed +myFS.truncate('other.txt', 5, (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/dynamic-import.mjs b/recipes/fs-truncate-fd-deprecation/tests/expected/dynamic-import.mjs new file mode 100644 index 00000000..3da66cfa --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/dynamic-import.mjs @@ -0,0 +1,11 @@ +import('node:fs').then(fs => { + // fd usage should be transformed + const fd = fs.openSync('file.txt', 'w'); + fs.ftruncateSync(fd, 10); + fs.closeSync(fd); + + // path usage should not be transformed + fs.truncate('other.txt', 5, (err) => { + if (err) throw err; + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/edge-case.mjs b/recipes/fs-truncate-fd-deprecation/tests/expected/edge-case.mjs new file mode 100644 index 00000000..a05e4f02 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/edge-case.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; + +export default function truncateFile() { + fs.open('file.txt', 'w', (err, strangeName) => { + if (err) throw err; + fs.ftruncate(strangeName, 10, (err) => { + if (err) throw err; + fs.close(strangeName, () => { }); + }); + }); +} + +const accesible = fs.openSync('file.txt', 'w'); + +fs.ftruncateSync(accesible, 10); + +fs.closeSync(accesible); + +function foo() { + truncateFile(unaccessible, 10); +} + +function bar() { + const unaccessible = fs.openSync('file.txt', 'w'); +} diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/file-1.js b/recipes/fs-truncate-fd-deprecation/tests/expected/file-1.js new file mode 100644 index 00000000..75fa3ae3 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/file-1.js @@ -0,0 +1,9 @@ +const { ftruncate, open, close } = require('node:fs'); + +open('file.txt', 'w', (err, fd) => { + if (err) throw err; + ftruncate(fd, 10, (err) => { + if (err) throw err; + close(fd, () => { }); + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/file-2.js b/recipes/fs-truncate-fd-deprecation/tests/expected/file-2.js new file mode 100644 index 00000000..c363686d --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/file-2.js @@ -0,0 +1,8 @@ +const fs = require('node:fs'); + +const fd = fs.openSync('file.txt', 'w'); +try { + fs.ftruncateSync(fd, 10); +} finally { + fs.closeSync(fd); +} diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/file-3.js b/recipes/fs-truncate-fd-deprecation/tests/expected/file-3.js new file mode 100644 index 00000000..96a5f724 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/file-3.js @@ -0,0 +1,11 @@ +const { truncate, ftruncateSync, open, openSync, close, closeSync } = require('node:fs'); + +// This should be replaced (file descriptor) +const fd = openSync('file.txt', 'w'); +ftruncateSync(fd, 10); +closeSync(fd); + +// This should NOT be replaced (file path) +truncate('other.txt', 5, (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/file-4.mjs b/recipes/fs-truncate-fd-deprecation/tests/expected/file-4.mjs new file mode 100644 index 00000000..fbcdf61c --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/file-4.mjs @@ -0,0 +1,9 @@ +import { ftruncate, open, close } from 'node:fs'; + +open('file.txt', 'w', (err, fd) => { + if (err) throw err; + ftruncate(fd, 10, (err) => { + if (err) throw err; + close(fd, () => { }); + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/expected/file-5.mjs b/recipes/fs-truncate-fd-deprecation/tests/expected/file-5.mjs new file mode 100644 index 00000000..04245b06 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/expected/file-5.mjs @@ -0,0 +1,9 @@ +import fs from 'node:fs'; + +fs.open('file.txt', 'w', (err, fd) => { + if (err) throw err; + fs.ftruncate(fd, 10, (err) => { + if (err) throw err; + fs.close(fd, () => { }); + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/dynamic-import-await.mjs b/recipes/fs-truncate-fd-deprecation/tests/input/dynamic-import-await.mjs new file mode 100644 index 00000000..bbc7af60 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/dynamic-import-await.mjs @@ -0,0 +1,11 @@ +const myFS = await import('node:fs'); + +// fd usage should be transformed +const fd = myFS.openSync('file.txt', 'w'); +myFS.ftruncateSync(fd, 10); +myFS.closeSync(fd); + +// path usage should not be transformed +myFS.truncate('other.txt', 5, (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/dynamic-import.mjs b/recipes/fs-truncate-fd-deprecation/tests/input/dynamic-import.mjs new file mode 100644 index 00000000..3da66cfa --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/dynamic-import.mjs @@ -0,0 +1,11 @@ +import('node:fs').then(fs => { + // fd usage should be transformed + const fd = fs.openSync('file.txt', 'w'); + fs.ftruncateSync(fd, 10); + fs.closeSync(fd); + + // path usage should not be transformed + fs.truncate('other.txt', 5, (err) => { + if (err) throw err; + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/edge-case.mjs b/recipes/fs-truncate-fd-deprecation/tests/input/edge-case.mjs new file mode 100644 index 00000000..f9214366 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/edge-case.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; + +export default function truncateFile() { + fs.open('file.txt', 'w', (err, strangeName) => { + if (err) throw err; + fs.truncate(strangeName, 10, (err) => { + if (err) throw err; + fs.close(strangeName, () => { }); + }); + }); +} + +const accesible = fs.openSync('file.txt', 'w'); + +fs.truncateSync(accesible, 10); + +fs.closeSync(accesible); + +function foo() { + truncateFile(unaccessible, 10); +} + +function bar() { + const unaccessible = fs.openSync('file.txt', 'w'); +} diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/file-1.js b/recipes/fs-truncate-fd-deprecation/tests/input/file-1.js new file mode 100644 index 00000000..db75880b --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/file-1.js @@ -0,0 +1,9 @@ +const { truncate, open, close } = require('node:fs'); + +open('file.txt', 'w', (err, fd) => { + if (err) throw err; + truncate(fd, 10, (err) => { + if (err) throw err; + close(fd, () => { }); + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/file-2.js b/recipes/fs-truncate-fd-deprecation/tests/input/file-2.js new file mode 100644 index 00000000..aefa3344 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/file-2.js @@ -0,0 +1,8 @@ +const fs = require('node:fs'); + +const fd = fs.openSync('file.txt', 'w'); +try { + fs.truncateSync(fd, 10); +} finally { + fs.closeSync(fd); +} diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/file-3.js b/recipes/fs-truncate-fd-deprecation/tests/input/file-3.js new file mode 100644 index 00000000..96a5f724 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/file-3.js @@ -0,0 +1,11 @@ +const { truncate, ftruncateSync, open, openSync, close, closeSync } = require('node:fs'); + +// This should be replaced (file descriptor) +const fd = openSync('file.txt', 'w'); +ftruncateSync(fd, 10); +closeSync(fd); + +// This should NOT be replaced (file path) +truncate('other.txt', 5, (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/file-4.mjs b/recipes/fs-truncate-fd-deprecation/tests/input/file-4.mjs new file mode 100644 index 00000000..cc4dd98c --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/file-4.mjs @@ -0,0 +1,9 @@ +import { truncate, open, close } from 'node:fs'; + +open('file.txt', 'w', (err, fd) => { + if (err) throw err; + truncate(fd, 10, (err) => { + if (err) throw err; + close(fd, () => { }); + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/tests/input/file-5.mjs b/recipes/fs-truncate-fd-deprecation/tests/input/file-5.mjs new file mode 100644 index 00000000..78fed4b1 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/input/file-5.mjs @@ -0,0 +1,9 @@ +import fs from 'node:fs'; + +fs.open('file.txt', 'w', (err, fd) => { + if (err) throw err; + fs.truncate(fd, 10, (err) => { + if (err) throw err; + fs.close(fd, () => { }); + }); +}); diff --git a/recipes/fs-truncate-fd-deprecation/workflow.yaml b/recipes/fs-truncate-fd-deprecation/workflow.yaml new file mode 100644 index 00000000..e4777e8f --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/workflow.yaml @@ -0,0 +1,25 @@ +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Apply JS AST Grep transformations + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript