diff --git a/packages/cli/src/constructs/api-check.ts b/packages/cli/src/constructs/api-check.ts index 5f3a7224..24b10363 100644 --- a/packages/cli/src/constructs/api-check.ts +++ b/packages/cli/src/constructs/api-check.ts @@ -3,7 +3,6 @@ import { Check, CheckProps } from './check' import { HttpHeader } from './http-header' import { Session } from './project' import { QueryParam } from './query-param' -import { Parser } from '../services/check-parser/parser' import { pathToPosix } from '../services/util' import { printDeprecationWarning } from '../reporters/util' import { Content, Entrypoint } from './construct' @@ -352,10 +351,7 @@ export class ApiCheck extends Check { if (!runtime) { throw new Error(`${runtimeId} is not supported`) } - const parser = new Parser({ - supportedNpmModules: Object.keys(runtime.dependencies), - checkUnsupportedModules: Session.verifyRuntimeDependencies, - }) + const parser = Session.getParser(runtime) const parsed = parser.parse(absoluteEntrypoint) // Maybe we can get the parsed deps with the content immediately diff --git a/packages/cli/src/constructs/browser-check.ts b/packages/cli/src/constructs/browser-check.ts index ce139d16..2fc4b58e 100644 --- a/packages/cli/src/constructs/browser-check.ts +++ b/packages/cli/src/constructs/browser-check.ts @@ -1,7 +1,6 @@ import * as path from 'path' import { Check, CheckProps } from './check' import { Session } from './project' -import { Parser } from '../services/check-parser/parser' import { CheckConfigDefaults } from '../services/checkly-config-loader' import { pathToPosix } from '../services/util' import { Content, Entrypoint } from './construct' @@ -119,10 +118,7 @@ export class BrowserCheck extends Check { if (!runtime) { throw new Error(`${runtimeId} is not supported`) } - const parser = new Parser({ - supportedNpmModules: Object.keys(runtime.dependencies), - checkUnsupportedModules: Session.verifyRuntimeDependencies, - }) + const parser = Session.getParser(runtime) const parsed = parser.parse(entry) // Maybe we can get the parsed deps with the content immediately diff --git a/packages/cli/src/constructs/multi-step-check.ts b/packages/cli/src/constructs/multi-step-check.ts index 8e1500e9..83718930 100644 --- a/packages/cli/src/constructs/multi-step-check.ts +++ b/packages/cli/src/constructs/multi-step-check.ts @@ -1,7 +1,6 @@ import * as path from 'path' import { Check, CheckProps } from './check' import { Session } from './project' -import { Parser } from '../services/check-parser/parser' import { CheckConfigDefaults } from '../services/checkly-config-loader' import { pathToPosix } from '../services/util' import { Content, Entrypoint } from './construct' @@ -104,10 +103,7 @@ export class MultiStepCheck extends Check { if (!runtime) { throw new Error(`${runtimeId} is not supported`) } - const parser = new Parser({ - supportedNpmModules: Object.keys(runtime.dependencies), - checkUnsupportedModules: Session.verifyRuntimeDependencies, - }) + const parser = Session.getParser(runtime) const parsed = parser.parse(entry) // Maybe we can get the parsed deps with the content immediately diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 169c96b1..31553451 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -1,5 +1,6 @@ import * as api from '../rest/api' import { CheckConfigDefaults } from '../services/checkly-config-loader' +import { Parser } from '../services/check-parser/parser' import { Construct } from './construct' import { ValidationError } from './validator-error' @@ -147,6 +148,7 @@ export class Session { static loadingChecklyConfigFile: boolean static checklyConfigFileConstructs?: Construct[] static privateLocations: PrivateLocationApi[] + static parsers = new Map() static registerConstruct (construct: Construct) { if (Session.project) { @@ -191,4 +193,20 @@ export class Session { } return Session.availableRuntimes[effectiveRuntimeId] } + + static getParser (runtime: Runtime): Parser { + const cachedParser = Session.parsers.get(runtime.name) + if (cachedParser !== undefined) { + return cachedParser + } + + const parser = new Parser({ + supportedNpmModules: Object.keys(runtime.dependencies), + checkUnsupportedModules: Session.verifyRuntimeDependencies, + }) + + Session.parsers.set(runtime.name, parser) + + return parser + } } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js new file mode 100644 index 00000000..d99d91de --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js @@ -0,0 +1 @@ +export const value = 'hello' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js new file mode 100644 index 00000000..51a1e4c5 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js @@ -0,0 +1 @@ +export const value = 'world' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts new file mode 100644 index 00000000..e2befb13 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts @@ -0,0 +1 @@ +export const value = 3 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts new file mode 100644 index 00000000..f80d396f --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts @@ -0,0 +1,3 @@ +import { } from './dep1' +import { } from './dep2.js' +import { } from './dep3.js' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts new file mode 100644 index 00000000..d99d91de --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts @@ -0,0 +1 @@ +export const value = 'hello' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js new file mode 100644 index 00000000..51a1e4c5 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js @@ -0,0 +1 @@ +export const value = 'world' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js new file mode 100644 index 00000000..7bee0690 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js @@ -0,0 +1,4 @@ +import { } from './dep1' +import { } from './dep1.ts' +import { } from './dep1.js' +import { } from './dep2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts new file mode 100644 index 00000000..062a3151 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts @@ -0,0 +1 @@ +export const value1 = 'value1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts new file mode 100644 index 00000000..2407fa53 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts @@ -0,0 +1 @@ +export const value2 = 'value2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts new file mode 100644 index 00000000..28b05968 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts @@ -0,0 +1 @@ +export const value3 = 'value3' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts new file mode 100644 index 00000000..5ab303a3 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts @@ -0,0 +1,3 @@ +export { value1 } from './dep1.ts' +export { value2 } from './dep2.js' +export { value3 } from './dep3' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json new file mode 100644 index 00000000..ba221115 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": ".", + "noEmit": true, + "allowImportingTsExtensions": true + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version new file mode 100644 index 00000000..dc0bb0f4 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version @@ -0,0 +1 @@ +v22.12.0 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts new file mode 100644 index 00000000..3a9ab4b7 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'TSConfig Paths Sample Project', + logicalId: 'tsconfig-paths-sample-project', + checks: { + frequency: 10, + locations: ['us-east-1'], + tags: ['mac'], + runtimeId: '2024.09', + checkMatch: '**/__checks__/**/*.check.ts', + browserChecks: { + testMatch: '**/__checks__/**/*.spec.ts', + }, + }, + cli: { + runLocation: 'us-east-1', + reporters: ['list'], + }, +}) + +export default config diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts new file mode 100644 index 00000000..e867f53f --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts @@ -0,0 +1 @@ +export const value = 'file1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts new file mode 100644 index 00000000..2c448345 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts @@ -0,0 +1 @@ +export const value = 'file2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts new file mode 100644 index 00000000..7e2747b1 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts @@ -0,0 +1 @@ +export { value } from './file2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts new file mode 100644 index 00000000..2c448345 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts @@ -0,0 +1 @@ +export const value = 'file2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts new file mode 100644 index 00000000..538b6eeb --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts @@ -0,0 +1,3 @@ +export { value as value1 } from './file1' +export { value as value2 } from './file2' +export { value as value3 } from './folder/file1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json @@ -0,0 +1 @@ +{} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json new file mode 100644 index 00000000..8d48c93b --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json @@ -0,0 +1,12 @@ +{ + "name": "@internal/lib", + "main": "dist/index.js", + "scripts": { + "clean": "rimraf ./dist", + "prepare": "npm run clean && tsc --build" + }, + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.7.2" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json new file mode 100644 index 00000000..a9a89286 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json @@ -0,0 +1,18 @@ +{ + "exclude": [ + "dist", + "node_modules" + ], + "include": [ + "./**/*.ts" + ], + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "outDir": "dist", + "declaration": true, + "sourceMap": true, + "declarationMap": true + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts new file mode 100644 index 00000000..ea677927 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts @@ -0,0 +1 @@ +export const name = 'lib2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts new file mode 100644 index 00000000..02d000b6 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts @@ -0,0 +1 @@ +export const value = 11517 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json new file mode 100644 index 00000000..7eb29aae --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": "." + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json @@ -0,0 +1 @@ +{} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json new file mode 100644 index 00000000..0b60ed9a --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json @@ -0,0 +1,10 @@ +{ + "name": "tsconfig-paths-sample-project", + "dependencies": { + "checkly": "^4.15.0" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "typescript": "^5.7.2" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts new file mode 100644 index 00000000..31454212 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts @@ -0,0 +1,3 @@ +import { value1 } from '@internal/lib1' +import { value } from '@/foo/bar' +import { name } from 'lib2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json new file mode 100644 index 00000000..b663f283 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@internal/lib1": [ + "./lib1" + ], + "@/*": [ + "./lib3/*" + ] + } + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts new file mode 100644 index 00000000..c385a564 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts @@ -0,0 +1 @@ +export const value = 'nothing here' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json new file mode 100644 index 00000000..b663f283 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@internal/lib1": [ + "./lib1" + ], + "@/*": [ + "./lib3/*" + ] + } + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts b/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts index 783e77a5..5612c7c6 100644 --- a/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts @@ -127,6 +127,81 @@ describe('dependency-parser - parser()', () => { ]) }) + it('should parse typescript dependencies using tsconfig', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-paths-sample-project', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([ + toAbsolutePath('lib1', 'file1.ts'), + toAbsolutePath('lib1', 'file2.ts'), + toAbsolutePath('lib1', 'folder', 'file1.ts'), + toAbsolutePath('lib1', 'folder', 'file2.ts'), + toAbsolutePath('lib1', 'index.ts'), + toAbsolutePath('lib1', 'package.json'), + toAbsolutePath('lib1', 'tsconfig.json'), + toAbsolutePath('lib2', 'index.ts'), + toAbsolutePath('lib3', 'foo', 'bar.ts'), + toAbsolutePath('tsconfig.json'), + ]) + }) + + it('should not include tsconfig if not needed', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-paths-unused', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([]) + }) + + it('should support importing ts extensions if allowed', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-allow-importing-ts-extensions', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([ + toAbsolutePath('src', 'dep1.ts'), + toAbsolutePath('src', 'dep2.ts'), + toAbsolutePath('src', 'dep3.ts'), + ]) + }) + + it('should not import TS files from a JS file', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'no-import-ts-from-js', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + expect.assertions(1) + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dependencies } = parser.parse(toAbsolutePath('entrypoint.js')) + } catch (err) { + expect(err).toMatchObject({ + missingFiles: [ + toAbsolutePath('dep1'), + toAbsolutePath('dep1.ts'), + toAbsolutePath('dep1.js'), + ], + }) + } + }) + + it('should import JS files from a TS file', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'import-js-from-ts', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([ + toAbsolutePath('dep1.js'), + toAbsolutePath('dep2.js'), + toAbsolutePath('dep3.ts'), + ]) + }) + it('should handle ES Modules', () => { const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'esmodules-example', ...filepath) const parser = new Parser({ diff --git a/packages/cli/src/services/check-parser/package-files/extension.ts b/packages/cli/src/services/check-parser/package-files/extension.ts new file mode 100644 index 00000000..d1510f5e --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/extension.ts @@ -0,0 +1,76 @@ +import path from 'node:path' + +export type CoreExtension = '.js' | '.mjs' | '.cjs' | '.json' + +export const CoreExtensions: CoreExtension[] = ['.js', '.mjs', '.cjs', '.json'] + +type CoreExtensionMapping = { + [key in CoreExtension]: string[] +} + +/** + * @see https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution + */ +export const tsCoreExtensionLookupOrder: CoreExtensionMapping = { + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], + '.json': ['.json'], +} + +export type TSExtension = '.ts' | '.mts' | '.tsx' + +export const TSExtensions: TSExtension[] = ['.ts', '.mts', '.tsx'] + +type TSExtensionMapping = { + [key in TSExtension]: string[] +} + +export const tsExtensionLookupOrder: TSExtensionMapping = { + '.ts': ['.ts'], + '.mts': ['.mts'], + '.tsx': ['.tsx'], +} + +function stripKnownSuffix (value: string, suffix: string): string { + return value.substring(0, value.length - suffix.length) +} + +export class FileExtPath { + filePath: string + ext: string + + private constructor (filePath: string, ext: string) { + this.filePath = filePath + this.ext = ext + } + + static fromFilePath (filePath: string) { + const ext = path.extname(filePath) + return new FileExtPath(filePath, ext) + } + + hasCoreExtension () { + return this.ext in tsCoreExtensionLookupOrder + } + + hasTypeScriptExtension () { + return this.ext in tsExtensionLookupOrder + } + + replaceExt (ext: string) { + return stripKnownSuffix(this.filePath, this.ext) + ext + } + + appendExt (ext: string) { + return this.filePath + ext + } + + resolve (...paths: string[]) { + return path.resolve(this.filePath, ...paths) + } + + self () { + return this.filePath + } +} diff --git a/packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts b/packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts new file mode 100644 index 00000000..95b7b49b --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts @@ -0,0 +1,27 @@ +import path from 'node:path' + +import { TSConfigFile, Schema } from './tsconfig-json-file' +import { JsonSourceFile } from './json-source-file' + +/** + * JSConfigFile is essentially the exact same as TSConfigFile but with + * allowJs implicitly enabled. + * + * While we could handle jsconfig.json with just TSConfigFile, it's not that + * much extra trouble to have a separate wrapper for it and doing it this way + * may enable some interesting features later. + */ +export class JSConfigFile extends TSConfigFile { + static FILENAME = 'jsconfig.json' + + static #id = 0 + readonly id = ++JSConfigFile.#id + + static filePath (dirPath: string) { + return path.join(dirPath, JSConfigFile.FILENAME) + } + + static loadFromJsonSourceFile (jsonFile: JsonSourceFile): JSConfigFile | undefined { + return new JSConfigFile(jsonFile) + } +} diff --git a/packages/cli/src/services/check-parser/package-files/json-source-file.ts b/packages/cli/src/services/check-parser/package-files/json-source-file.ts new file mode 100644 index 00000000..6dd5a350 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/json-source-file.ts @@ -0,0 +1,27 @@ +import { SourceFile } from './source-file' + +export class JsonSourceFile { + static #id = 0 + readonly id = ++JsonSourceFile.#id + + sourceFile: SourceFile + data: Schema + + private constructor (sourceFile: SourceFile, data: Schema) { + this.sourceFile = sourceFile + this.data = data + } + + public get meta () { + return this.sourceFile.meta + } + + static loadFromSourceFile (sourceFile: SourceFile): JsonSourceFile | undefined { + try { + const data: Schema = JSON.parse(sourceFile.contents) + + return new JsonSourceFile(sourceFile, data) + } catch (err: any) { + } + } +} diff --git a/packages/cli/src/services/check-parser/package-files/loader.ts b/packages/cli/src/services/check-parser/package-files/loader.ts new file mode 100644 index 00000000..b99a6bb1 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/loader.ts @@ -0,0 +1,19 @@ +export type LoadFile = (filePath: string) => T | undefined + +export class FileLoader { + loader: LoadFile + cache = new Map() + + constructor (loader: LoadFile) { + this.loader = loader + } + + load (filePath: string): T | undefined { + if (this.cache.has(filePath)) { + return this.cache.get(filePath) + } + const file = this.loader(filePath) + this.cache.set(filePath, file) + return file + } +} diff --git a/packages/cli/src/services/check-parser/package-files/lookup.ts b/packages/cli/src/services/check-parser/package-files/lookup.ts new file mode 100644 index 00000000..bfc106c4 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/lookup.ts @@ -0,0 +1,66 @@ +import { PackageJsonFile } from './package-json-file' +import { CoreExtension, FileExtPath, tsCoreExtensionLookupOrder, TSExtension, tsExtensionLookupOrder } from './extension' + +type Options = { + plainJs?: boolean +} + +export class LookupContext { + #plainJs: boolean + + constructor (options: Options) { + this.#plainJs = options.plainJs ?? false + } + + static forFilePath (filePath: string) { + return new LookupContext({ + plainJs: FileExtPath.fromFilePath(filePath).hasCoreExtension(), + }) + } + + collectLookupPaths (filePath: string): string[] { + const extPath = FileExtPath.fromFilePath(filePath) + + if (this.#plainJs) { + if (extPath.hasCoreExtension()) { + return [extPath.self()] + } + + return this.extlessCoreLookupPaths(extPath) + } + + if (extPath.hasCoreExtension()) { + const extensions = tsCoreExtensionLookupOrder[extPath.ext as CoreExtension] + return extensions.map(ext => extPath.replaceExt(ext)) + } + + if (extPath.hasTypeScriptExtension()) { + const extensions = tsExtensionLookupOrder[extPath.ext as TSExtension] + return extensions.map(ext => extPath.replaceExt(ext)) + } + + return this.extlessTSLookupPaths(extPath) + } + + private extlessCoreLookupPaths (extPath: FileExtPath): string[] { + return [ + extPath.appendExt('.js'), + extPath.appendExt('.mjs'), + extPath.appendExt('.cjs'), + extPath.appendExt('.json'), + extPath.resolve(PackageJsonFile.FILENAME), + extPath.resolve('index.js'), + extPath.resolve('index.mjs'), + extPath.resolve('index.cjs'), + extPath.resolve('index.json'), // Yes, this works. + ] + } + + private extlessTSLookupPaths (extPath: FileExtPath): string[] { + return this.extlessCoreLookupPaths(extPath).flatMap(filePath => { + const extPath = FileExtPath.fromFilePath(filePath) + const extensions = tsCoreExtensionLookupOrder[extPath.ext as CoreExtension] + return extensions.map(ext => extPath.replaceExt(ext)) + }) + } +} diff --git a/packages/cli/src/services/check-parser/package-files/package-json-file.ts b/packages/cli/src/services/check-parser/package-files/package-json-file.ts new file mode 100644 index 00000000..7a89ddcb --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/package-json-file.ts @@ -0,0 +1,45 @@ +import path from 'node:path' + +import { JsonSourceFile } from './json-source-file' + +type ExportCondition = + 'node-addons' | 'node' | 'import' | 'require' | 'module-sync' | 'default' + +type Schema = { + main?: string + exports?: string | string[] | Record | Record> +} + +export class PackageJsonFile { + static FILENAME = 'package.json' + + static #id = 0 + readonly id = ++PackageJsonFile.#id + + jsonFile: JsonSourceFile + basePath: string + mainPaths: string[] + + private constructor (jsonFile: JsonSourceFile) { + this.jsonFile = jsonFile + this.basePath = jsonFile.meta.dirname + + const fallbackMainPath = path.resolve(this.basePath, 'index.js') + + this.mainPaths = jsonFile.data.main !== undefined + ? [path.resolve(this.basePath, jsonFile.data.main), fallbackMainPath] + : [fallbackMainPath] + } + + public get meta () { + return this.jsonFile.meta + } + + static loadFromJsonSourceFile (jsonFile: JsonSourceFile): PackageJsonFile | undefined { + return new PackageJsonFile(jsonFile) + } + + static filePath (dirPath: string) { + return path.join(dirPath, PackageJsonFile.FILENAME) + } +} diff --git a/packages/cli/src/services/check-parser/package-files/paths.ts b/packages/cli/src/services/check-parser/package-files/paths.ts new file mode 100644 index 00000000..b3998d29 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/paths.ts @@ -0,0 +1,262 @@ +import path from 'node:path' + +type Paths = Record> + +class TargetPathSpec { + /** + * Prefix is the part of the path before an asterisk (wildcard), or the + * whole path if there's no asterisk. + * + * Examples of possible values: + * - `"./foo/*"` (from `"./foo/"`) + * - `"./bar/foo-"` (from `"./bar/foo-*.js"`) + * - `"./bar."` (from `"./bar.*.ts"`) + * - `""` (from `"*"`) + */ + prefix: string + + /** + * Suffix is the part of the path after the asterisk (wildcard), if any. + */ + suffix?: string + + protected constructor (prefix: string, suffix?: string) { + this.prefix = prefix + this.suffix = suffix + } + + toPath (joker?: string) { + if (this.suffix === undefined) { + return this.prefix + } + + if (joker === undefined) { + return this.prefix + this.suffix + } + + return this.prefix + joker + this.suffix + } + + static create (spec: string) { + const parts = spec.split('*', 2) + if (parts.length === 1) { + return new TargetPathSpec(spec) + } + const [prefix, suffix] = parts + return new TargetPathSpec(prefix, suffix) + } +} + +export type TargetPathResult = { + spec: TargetPathSpec, + path: string, +} + +class PathMatchResult { + ok: boolean + results: TargetPathResult[] + + private constructor (ok: boolean, results: TargetPathResult[]) { + this.ok = ok + this.results = results + } + + static some (results: TargetPathResult[]) { + return new PathMatchResult(true, results) + } + + static none () { + return new PathMatchResult(false, []) + } +} + +interface PathMatcher { + get prefixLength (): number + match (importPath: string): PathMatchResult +} + +class ExactPathMatcher implements PathMatcher { + prefix: string + target: TargetPathSpec[] + + constructor (prefix: string, target: TargetPathSpec[]) { + this.prefix = prefix + this.target = target + } + + get prefixLength (): number { + return this.prefix.length + } + + match (importPath: string): PathMatchResult { + if (importPath === this.prefix) { + return PathMatchResult.some(this.target.map(target => { + return { + spec: target, + path: target.toPath(), + } + })) + } + + return PathMatchResult.none() + } +} + +class WildcardPathMatcher implements PathMatcher { + prefix: string + suffix: string + target: TargetPathSpec[] + + constructor (prefix: string, suffix: string, target: TargetPathSpec[]) { + this.prefix = prefix + this.suffix = suffix + this.target = target + } + + get prefixLength (): number { + return this.prefix.length + } + + match (importPath: string): PathMatchResult { + if (importPath.startsWith(this.prefix) && importPath.endsWith(this.suffix)) { + const joker = importPath.substring(this.prefix.length, importPath.length - this.suffix.length) + return PathMatchResult.some(this.target.map(target => { + return { + spec: target, + path: target.toPath(joker), + } + })) + } + + return PathMatchResult.none() + } +} + +class SourcePathSpec { + /** + * Prefix is the part of the path before an asterisk (wildcard), or the + * whole path if there's no asterisk. + * + * Examples of possible values: + * - `"@/"` (from `"@/*"`) + * - `"app/foo-"` (from `"app/foo-*.js"`) + * - `"bar."` (from `"bar.*.ts"`) + * - `""` (from `"*"`) + */ + prefix: string + + /** + * Suffix is the part of the path after the asterisk (wildcard), if any. + */ + suffix?: string + + protected constructor (prefix: string, suffix?: string) { + this.prefix = prefix + this.suffix = suffix + } + + matcherForTarget (target: TargetPathSpec[]): PathMatcher { + if (this.suffix === undefined) { + return new ExactPathMatcher(this.prefix, target) + } + + return new WildcardPathMatcher(this.prefix, this.suffix, target) + } + + static create (spec: string) { + const parts = spec.split('*', 2) + if (parts.length === 1) { + return new SourcePathSpec(spec) + } + const [prefix, suffix] = parts + return new SourcePathSpec(prefix, suffix) + } +} + +type SourcePathSpecMatcher = { + spec: SourcePathSpec, + matcher: PathMatcher, +} + +export type SourcePathResult = { + spec: SourcePathSpec, + path: string +} + +export type PathResult = { + source: SourcePathResult + target: TargetPathResult +} + +export type ResolveResult = PathResult[] + +export class PathResolver { + baseUrl: string + matchers: SourcePathSpecMatcher[] + + private constructor (baseUrl: string, matchers: SourcePathSpecMatcher[]) { + this.baseUrl = baseUrl + + // Sort by longest prefix now, then we don't have to care about it later. + matchers.sort((a, b) => b.matcher.prefixLength - a.matcher.prefixLength) + + this.matchers = matchers + } + + resolve (importPath: string): ResolveResult { + for (const { spec, matcher } of this.matchers) { + const match = matcher.match(importPath) + if (match.ok) { + // We can just return the first match since matchers are already + // sorted by longest prefix. + return match.results.map(result => { + return { + source: { + spec, + path: importPath, + }, + target: result, + } + }) + } + } + + return [] + } + + static createFromPaths (baseUrl: string, paths: Paths): PathResolver { + const matchers: SourcePathSpecMatcher[] = [] + + for (const path in paths) { + matchers.push(PathResolver.matcherForPath(path, paths[path])) + } + + return new PathResolver(baseUrl, matchers) + } + + private static matcherForPath (spec: string, target: string[]): SourcePathSpecMatcher { + const pathSpec = SourcePathSpec.create(spec) + const matcher = pathSpec.matcherForTarget(target.map(TargetPathSpec.create)) + + return { + spec: pathSpec, + matcher, + } + } +} + +export function isLocalPath (importPath: string) { + if (importPath.startsWith('/')) { + return true + } + + if (importPath.startsWith('./')) { + return true + } + + if (importPath.startsWith('../')) { + return true + } + + return false +} diff --git a/packages/cli/src/services/check-parser/package-files/resolver.ts b/packages/cli/src/services/check-parser/package-files/resolver.ts new file mode 100644 index 00000000..072011b4 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/resolver.ts @@ -0,0 +1,357 @@ +/* eslint-disable no-labels */ + +import path from 'node:path' + +import { SourceFile } from './source-file' +import { PackageJsonFile } from './package-json-file' +import { TSConfigFile } from './tsconfig-json-file' +import { JSConfigFile } from './jsconfig-json-file' +import { isLocalPath, PathResult } from './paths' +import { FileLoader, LoadFile } from './loader' +import { JsonSourceFile } from './json-source-file' +import { LookupContext } from './lookup' + +class PackageFilesCache { + #sourceFileCache = new FileLoader(SourceFile.loadFromFilePath) + + #jsonFileLoader (load: (jsonFile: JsonSourceFile) => T | undefined): LoadFile { + return filePath => { + const sourceFile = this.#sourceFileCache.load(filePath) + if (sourceFile === undefined) { + return + } + + const jsonFile = JsonSourceFile.loadFromSourceFile(sourceFile) + if (jsonFile === undefined) { + return + } + + return load(jsonFile) + } + } + + #packageJsonCache = new FileLoader(this.#jsonFileLoader(PackageJsonFile.loadFromJsonSourceFile)) + #tsconfigJsonCache = new FileLoader(this.#jsonFileLoader(TSConfigFile.loadFromJsonSourceFile)) + #jsconfigJsonCache = new FileLoader(this.#jsonFileLoader(JSConfigFile.loadFromJsonSourceFile)) + + sourceFile (filePath: string, context: LookupContext) { + for (const lookupPath of context.collectLookupPaths(filePath)) { + const sourceFile = this.#sourceFileCache.load(lookupPath) + if (sourceFile === undefined) { + continue + } + + return sourceFile + } + } + + packageJson (filePath: string) { + return this.#packageJsonCache.load(filePath) + } + + tsconfigJson (filePath: string) { + return this.#tsconfigJsonCache.load(filePath) + } + + jsconfigJson (filePath: string) { + return this.#jsconfigJsonCache.load(filePath) + } +} + +class PackageFiles { + packageJson?: PackageJsonFile + tsconfigJson?: TSConfigFile + jsconfigJson?: JSConfigFile + + satisfyFromDirPath (dirPath: string, cache: PackageFilesCache): boolean { + if (this.packageJson === undefined) { + this.packageJson = cache.packageJson(PackageJsonFile.filePath(dirPath)) + } + + if (this.tsconfigJson === undefined && this.jsconfigJson === undefined) { + this.tsconfigJson = cache.tsconfigJson(TSConfigFile.filePath(dirPath)) + } + + if (this.jsconfigJson === undefined && this.tsconfigJson === undefined) { + this.jsconfigJson = cache.jsconfigJson(JSConfigFile.filePath(dirPath)) + } + + return this.satisfied + } + + get satisfied (): boolean { + // Never satisfied until we find a package.json file. + if (this.packageJson === undefined) { + return false + } + + // Not satisfied until either a tsconfig.json or a jsconfig.json file + // is found. + if (this.tsconfigJson === undefined && this.jsconfigJson === undefined) { + return false + } + + return true + } +} + +type TSConfigFileLocalDependency = { + kind: 'tsconfig-file' + importPath: string + sourceFile: SourceFile + configFile: TSConfigFile +} + +type TSConfigResolvedPathLocalDependency = { + kind: 'tsconfig-resolved-path' + importPath: string + sourceFile: SourceFile + configFile: TSConfigFile + pathResult: PathResult +} + +type TSConfigBaseUrlRelativePathLocalDependency = { + kind: 'tsconfig-baseurl-relative-path' + importPath: string + configFile: TSConfigFile + sourceFile: SourceFile +} + +type RelativePathLocalDependency = { + kind: 'relative-path' + importPath: string + sourceFile: SourceFile +} + +type LocalDependency = + TSConfigFileLocalDependency | + TSConfigResolvedPathLocalDependency | + TSConfigBaseUrlRelativePathLocalDependency | + RelativePathLocalDependency + +type MissingDependency = { + importPath: string, + filePath: string, +} + +type ExternalDependency = { + importPath: string +} + +export type Dependencies = { + external: ExternalDependency[], + missing: MissingDependency[], + local: LocalDependency[], +} + +export class PackageFilesResolver { + cache = new PackageFilesCache() + + loadPackageFiles (filePath: string, options?: { root?: string }): PackageFiles { + const files = new PackageFiles() + + let currentPath = filePath + + while (true) { + const prevPath = currentPath + + currentPath = path.dirname(prevPath) + + // Bail out if we reach root. + if (prevPath === currentPath) { + break + } + + // Try to find all files and stop if we do. + if (files.satisfyFromDirPath(currentPath, this.cache)) { + break + } + + // Stop if we reach the user-specified root directory. + // TODO: I don't like a string comparison for this but it'll do for now. + if (currentPath === options?.root) { + break + } + } + + return files + } + + private resolveSourceFile (sourceFile: SourceFile, context: LookupContext): SourceFile[] { + if (sourceFile.meta.basename === PackageJsonFile.FILENAME) { + const packageJson = this.cache.packageJson(sourceFile.meta.filePath) + if (packageJson === undefined) { + // This should never happen unless the package.json is invalid or + // something. + return [sourceFile] + } + + // Go through each main path. A fallback path is included. If we can + // find a tsconfig for the main file, look it up and attempt to find + // the original TypeScript sources roughly the same way tsc does it. + for (const mainPath of packageJson.mainPaths) { + const { tsconfigJson, jsconfigJson } = this.loadPackageFiles(mainPath, { + root: packageJson.basePath, + }) + + // TODO: Prefer jsconfig.json when dealing with a JavaScript file. + for (const configJson of [tsconfigJson, jsconfigJson]) { + if (configJson === undefined) { + continue + } + + const candidatePaths = configJson.collectLookupPaths(mainPath).flatMap(filePath => { + return context.collectLookupPaths(filePath) + }) + for (const candidatePath of candidatePaths) { + const mainSourceFile = this.cache.sourceFile(candidatePath, context) + if (mainSourceFile === undefined) { + continue + } + + configJson.registerRelatedSourceFile(mainSourceFile) + + return [sourceFile, mainSourceFile, configJson.jsonFile.sourceFile] + } + } + + const mainSourceFile = this.cache.sourceFile(mainPath, context) + if (mainSourceFile === undefined) { + continue + } + + return [sourceFile, mainSourceFile] + } + + // TODO: Is this even useful without any code files? + return [sourceFile] + } + + return [sourceFile] + } + + resolveDependenciesForFilePath ( + filePath: string, + dependencies: string[], + ): Dependencies { + const resolved: Dependencies = { + external: [], + missing: [], + local: [], + } + + const dirname = path.dirname(filePath) + + const { tsconfigJson, jsconfigJson } = this.loadPackageFiles(filePath) + + const context = LookupContext.forFilePath(filePath) + + resolve: + for (const importPath of dependencies) { + if (isLocalPath(importPath)) { + const relativeDepPath = path.resolve(dirname, importPath) + const sourceFile = this.cache.sourceFile(relativeDepPath, context) + if (sourceFile !== undefined) { + const resolvedFiles = this.resolveSourceFile(sourceFile, context) + let found = false + for (const resolvedFile of resolvedFiles) { + resolved.local.push({ + kind: 'relative-path', + importPath, + sourceFile: resolvedFile, + }) + found = true + } + if (found) { + continue resolve + } + } + resolved.missing.push({ + importPath, + filePath: relativeDepPath, + }) + continue resolve + } + + for (const configJson of [tsconfigJson, jsconfigJson]) { + if (configJson === undefined) { + continue + } + + const resolvedPaths = configJson.resolvePath(importPath) + if (resolvedPaths.length > 0) { + let found = false + for (const { source, target } of resolvedPaths) { + const relativePath = path.resolve(configJson.basePath, target.path) + const sourceFile = this.cache.sourceFile(relativePath, context) + if (sourceFile !== undefined) { + const resolvedFiles = this.resolveSourceFile(sourceFile, context) + for (const resolvedFile of resolvedFiles) { + configJson.registerRelatedSourceFile(resolvedFile) + resolved.local.push({ + kind: 'tsconfig-resolved-path', + importPath, + sourceFile: resolvedFile, + configFile: configJson, + pathResult: { + source, + target, + }, + }) + resolved.local.push({ + kind: 'tsconfig-file', + importPath, + sourceFile: configJson.jsonFile.sourceFile, + configFile: configJson, + }) + found = true + } + if (found) { + // We're trying to find the first match out of many possible + // candidates. Stop once we find a match. + break + } + } + } + if (found) { + continue resolve + } + } + + if (configJson.baseUrl !== undefined) { + const relativePath = path.resolve(configJson.basePath, configJson.baseUrl, importPath) + const sourceFile = this.cache.sourceFile(relativePath, context) + if (sourceFile !== undefined) { + const resolvedFiles = this.resolveSourceFile(sourceFile, context) + let found = false + for (const resolvedFile of resolvedFiles) { + configJson.registerRelatedSourceFile(resolvedFile) + resolved.local.push({ + kind: 'tsconfig-baseurl-relative-path', + importPath, + sourceFile: resolvedFile, + configFile: configJson, + }) + resolved.local.push({ + kind: 'tsconfig-file', + importPath, + sourceFile: configJson.jsonFile.sourceFile, + configFile: configJson, + }) + found = true + } + if (found) { + continue resolve + } + } + } + } + + resolved.external.push({ + importPath, + }) + } + + return resolved + } +} diff --git a/packages/cli/src/services/check-parser/package-files/source-file.ts b/packages/cli/src/services/check-parser/package-files/source-file.ts new file mode 100644 index 00000000..d04bce2d --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/source-file.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import path from 'node:path' + +export class FileMeta { + filePath: string + dirname: string + basename: string + + private constructor (filePath: string, dirname: string, basename: string) { + this.filePath = filePath + this.dirname = dirname + this.basename = basename + } + + static fromFilePath (filePath: string): FileMeta { + return new FileMeta( + filePath, + path.dirname(filePath), + path.basename(filePath), + ) + } +} + +export class SourceFile { + static #id = 0 + readonly id = ++SourceFile.#id + + contents: string + meta: FileMeta + + private constructor (meta: FileMeta, contents: string) { + this.meta = meta + this.contents = contents + } + + static loadFromFilePath (filePath: string): SourceFile | undefined { + try { + const contents = fs.readFileSync(filePath, { + encoding: 'utf8', + }) + + const meta = FileMeta.fromFilePath(filePath) + + return new SourceFile(meta, contents) + } catch (err: any) { + } + } +} diff --git a/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts b/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts new file mode 100644 index 00000000..e47502cf --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts @@ -0,0 +1,165 @@ +import path from 'node:path' + +import { SourceFile } from './source-file' +import { JsonSourceFile } from './json-source-file' +import { PathResolver, ResolveResult } from './paths' + +type Module = + 'none' | 'commonjs' | 'amd' | 'system' | 'es6' | 'es2015' | 'es2020' | + 'es2022' | 'esnext' | 'node16' | 'nodenext' | 'preserve' + +type ModuleResolution = + 'classic' | 'node10' | 'node' | 'node16' | 'nodenext' | 'bundler' + +type Paths = Record> + +interface CompilerOptions { + module?: Module + moduleResolution?: ModuleResolution + baseUrl?: string + paths?: Paths + + /** + * If set, .js files will be emitted into this directory. + * + * If not set, .js files are placed right next to .ts files in the same + * folder. + * + * @see https://www.typescriptlang.org/tsconfig/#outDir + */ + outDir?: string + + /** + * If not set, rootDir is inferred to be the longest common path of all + * non-declaration input files, unless `composite: true`, in which case + * the inferred root is the directory containing the tsconfig.json file. + * + * @see https://www.typescriptlang.org/tsconfig/#rootDir + */ + rootDir?: string + + /** + * Allow multiple directions to act as a single root. Source files will be + * able to refer to files in other roots as if they were present in the same + * root. + * + * @see https://www.typescriptlang.org/tsconfig/#rootDirs + */ + rootDirs?: string[] + + /** + * If true, the default rootDir is the directory containing the + * tsconfig.json file. + * + * @see https://www.typescriptlang.org/tsconfig/#composite + */ + composite?: boolean +} + +export interface Schema { + compilerOptions?: CompilerOptions +} + +export class TSConfigFile { + static FILENAME = 'tsconfig.json' + + static #id = 0 + readonly id = ++TSConfigFile.#id + + jsonFile: JsonSourceFile + basePath: string + moduleResolution: string + baseUrl?: string + pathResolver: PathResolver + + relatedSourceFiles: SourceFile[] = [] + + protected constructor (jsonFile: JsonSourceFile) { + this.jsonFile = jsonFile + + this.basePath = jsonFile.meta.dirname + + this.moduleResolution = jsonFile.data.compilerOptions?.moduleResolution?.toLocaleLowerCase() ?? 'unspecified' + + const baseUrl = jsonFile.data.compilerOptions?.baseUrl + if (baseUrl !== undefined) { + this.baseUrl = path.resolve(this.jsonFile.meta.dirname, baseUrl) + } + + this.pathResolver = PathResolver.createFromPaths(this.baseUrl ?? '.', jsonFile.data.compilerOptions?.paths ?? {}) + } + + public get meta () { + return this.jsonFile.meta + } + + static loadFromJsonSourceFile (jsonFile: JsonSourceFile): TSConfigFile | undefined { + return new TSConfigFile(jsonFile) + } + + static filePath (dirPath: string) { + return path.join(dirPath, TSConfigFile.FILENAME) + } + + resolvePath (importPath: string): ResolveResult { + return this.pathResolver.resolve(importPath) + } + + collectLookupPaths (filePath: string): string[] { + let { + outDir, + rootDir, + rootDirs, + composite, + } = this.jsonFile.data.compilerOptions ?? {} + + const candidates = [] + + if (outDir === undefined) { + candidates.push(path.resolve(this.basePath, filePath)) + return candidates // Nothing more we can do. + } + + if (composite === undefined) { + composite = false + } + + // Inferred rootDir is tsconfig directory if composite === true. + if (rootDir === undefined && composite) { + rootDir = '.' + } + + // If we still don't have a root, we should calculate the longest common + // path among input files, but that's a lot of effort. Assume tsconfig + // directory and hope for the best. + if (rootDir === undefined) { + rootDir = '.' + } + + const absoluteOutDir = path.resolve(this.basePath, outDir) + const relativePath = path.relative(absoluteOutDir, filePath) + + // If the file is outside outDir, then assume we're looking for + // something that wasn't compiled using this tsconfig (or at all), and + // stop looking. + if (relativePath.startsWith('..')) { + candidates.push(path.resolve(this.basePath, filePath)) + return candidates + } + + candidates.push(path.resolve(this.basePath, rootDir, relativePath)) + + // Assume that our inferred (or user specified) rootDir is enough to cover + // the same conditions we'd have to infer rootDirs, and only add rootDirs + // if they're actually set. + for (const multiRootDir of rootDirs ?? []) { + candidates.push(path.resolve(this.basePath, multiRootDir, relativePath)) + } + + return candidates + } + + registerRelatedSourceFile (file: SourceFile) { + this.relatedSourceFiles.push(file) + } +} diff --git a/packages/cli/src/services/check-parser/parser.ts b/packages/cli/src/services/check-parser/parser.ts index 821b0c19..5ef668fa 100644 --- a/packages/cli/src/services/check-parser/parser.ts +++ b/packages/cli/src/services/check-parser/parser.ts @@ -4,6 +4,7 @@ import * as acorn from 'acorn' import * as walk from 'acorn-walk' import { Collector } from './collector' import { DependencyParseError } from './errors' +import { PackageFilesResolver, Dependencies } from './package-files/resolver' // Only import types given this is an optional dependency import type { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree' @@ -12,34 +13,13 @@ import type { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-est const ignore = (_node: any, _st: any, _c: any) => {} type Module = { - localDependencies: Array, - npmDependencies: Array + dependencies: Array, } type SupportedFileExtension = '.js' | '.mjs' | '.ts' const PACKAGE_EXTENSION = `${path.sep}package.json` -const JS_RESOLVE_ORDER = [ - '.js', - '.mjs', - PACKAGE_EXTENSION, - `${path.sep}index.js`, - // TODO: Check the module type in package.json to figure out the esm and common js file extensions - `${path.sep}index.mjs`, -] - -const TS_RESOLVE_ORDER = [ - '.ts', - '.js', - '.mjs', - PACKAGE_EXTENSION, - `${path.sep}index.ts`, - `${path.sep}index.js`, - // TODO: Check the module type in package.json to figure out the esm and common js file extensions - `${path.sep}index.mjs`, -] - const supportedBuiltinModules = [ 'assert', 'buffer', 'crypto', 'dns', 'fs', 'path', 'querystring', 'readline ', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'util', 'zlib', @@ -93,6 +73,12 @@ type ParserOptions = { export class Parser { supportedModules: Set checkUnsupportedModules: boolean + resolver = new PackageFilesResolver() + cache = new Map() // TODO: pass a npm matrix of supported npm modules // Maybe pass a cache so we don't have to fetch files separately all the time @@ -102,7 +88,7 @@ export class Parser { } parse (entrypoint: string) { - const { extension, content } = validateEntrypoint(entrypoint) + const { content } = validateEntrypoint(entrypoint) /* * The importing of files forms a directed graph. @@ -122,34 +108,51 @@ export class Parser { // Holds info about the main file and doesn't need to be parsed continue } - const { module, error } = Parser.parseDependencies(item.filePath, item.content) + + // This cache is only useful when there are multiple entrypoints with + // common files, as we make sure to not add the same file twice to + // bfsQueue. + const cache = this.cache.get(item.filePath) + const { module, error } = cache !== undefined + ? cache + : Parser.parseDependencies(item.filePath, item.content) + if (error) { + this.cache.set(item.filePath, { module, error }) collector.addParsingError(item.filePath, error.message) continue } + + const resolvedDependencies = cache?.resolvedDependencies ?? + this.resolver.resolveDependenciesForFilePath(item.filePath, module.dependencies) + + this.cache.set(item.filePath, { module, resolvedDependencies }) + if (this.checkUnsupportedModules) { - const unsupportedDependencies = module.npmDependencies.filter((dep) => !this.supportedModules.has(dep)) + const unsupportedDependencies = resolvedDependencies.external.flatMap(dep => { + if (!this.supportedModules.has(dep.importPath)) { + return [dep.importPath] + } else { + return [] + } + }) if (unsupportedDependencies.length) { collector.addUnsupportedNpmDependencies(item.filePath, unsupportedDependencies) } } - const localDependenciesResolvedPaths: Array<{filePath: string, content: string}> = [] - module.localDependencies.forEach((localDependency: string) => { - const filePath = path.join(path.dirname(item.filePath), localDependency) - try { - const deps = Parser.readDependency(filePath, extension) - localDependenciesResolvedPaths.push(...deps) - } catch (err: any) { - collector.addMissingFile(filePath) - } - }) - localDependenciesResolvedPaths.forEach(({ filePath, content }: {filePath: string, content: string}) => { + + for (const dep of resolvedDependencies.missing) { + collector.addMissingFile(dep.filePath) + } + + for (const dep of resolvedDependencies.local) { + const filePath = dep.sourceFile.meta.filePath if (collector.hasDependency(filePath)) { - return + continue } - collector.addDependency(filePath, content) - bfsQueue.push({ filePath, content }) - }) + collector.addDependency(filePath, dep.sourceFile.contents) + bfsQueue.push({ filePath, content: dep.sourceFile.contents }) + } } collector.validate() @@ -157,45 +160,9 @@ export class Parser { return collector.getItems() } - static readDependency (filePath: string, preferedExtenstion: SupportedFileExtension) { - // Read the specific file if it has an extension - if (preferedExtenstion === '.js') { - return Parser.tryReadFileExt(filePath, JS_RESOLVE_ORDER) - } else { - return Parser.tryReadFileExt(filePath, TS_RESOLVE_ORDER) - } - } - - static tryReadFileExt (filePath: string, exts: typeof JS_RESOLVE_ORDER | typeof TS_RESOLVE_ORDER) { - for (const extension of ['', ...exts]) { - try { - const deps = [] - const fullPath = filePath + extension - const content = fs.readFileSync(fullPath, { encoding: 'utf-8' }) - deps.push({ filePath: fullPath, content }) - if (extension === PACKAGE_EXTENSION) { - const { main } = JSON.parse(content) - // TODO: Check the module type to figure out the esm and common js file extensions - // It might be different than js and mjs - if (!main || !main.length) { - // No main is defined. This means package.json doesn't have a specific entry - continue - } - const mainFile = path.join(filePath, main) - deps.push({ - filePath: mainFile, content: fs.readFileSync(mainFile, { encoding: 'utf-8' }), - }) - } - return deps - } catch (err) {} - } - throw new Error(`Cannot find file ${filePath}`) - } - static parseDependencies (filePath: string, contents: string): { module: Module, error?: any } { - const localDependencies = new Set() - const npmDependencies = new Set() + const dependencies = new Set() const extension = path.extname(filePath) try { @@ -206,22 +173,23 @@ export class Parser { allowImportExportEverywhere: true, allowAwaitOutsideFunction: true, }) - walk.simple(ast, Parser.jsNodeVisitor(localDependencies, npmDependencies)) + walk.simple(ast, Parser.jsNodeVisitor(dependencies)) } else if (extension === '.ts') { const tsParser = getTsParser() const ast = tsParser.parse(contents, {}) // The AST from typescript-estree is slightly different from the type used by acorn-walk. // This doesn't actually cause problems (both are "ESTree's"), but we need to ignore type errors here. // @ts-ignore - walk.simple(ast, Parser.tsNodeVisitor(tsParser, localDependencies, npmDependencies)) + walk.simple(ast, Parser.tsNodeVisitor(tsParser, dependencies)) + } else if (extension === '.json') { + // No dependencies to check. } else { throw new Error(`Unsupported file extension for ${filePath}`) } } catch (err) { return { module: { - localDependencies: Array.from(localDependencies), - npmDependencies: Array.from(npmDependencies), + dependencies: Array.from(dependencies), }, error: err, } @@ -229,55 +197,54 @@ export class Parser { return { module: { - localDependencies: Array.from(localDependencies), - npmDependencies: Array.from(npmDependencies), + dependencies: Array.from(dependencies), }, } } - static jsNodeVisitor (localDependencies: Set, npmDependencies: Set): any { + static jsNodeVisitor (dependencies: Set): any { return { CallExpression (node: Node) { if (!Parser.isRequireExpression(node)) return const requireStringArg = Parser.getRequireStringArg(node) - Parser.registerDependency(requireStringArg, localDependencies, npmDependencies) + Parser.registerDependency(requireStringArg, dependencies) }, ImportDeclaration (node: any) { if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportNamedDeclaration (node: any) { if (node.source === null) return if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportAllDeclaration (node: any) { if (node.source === null) return if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, } } - static tsNodeVisitor (tsParser: any, localDependencies: Set, npmDependencies: Set): any { + static tsNodeVisitor (tsParser: any, dependencies: Set): any { return { ImportDeclaration (node: TSESTree.ImportDeclaration) { // For now, we only support literal strings in the import statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportNamedDeclaration (node: TSESTree.ExportNamedDeclaration) { // The statement isn't importing another dependency if (node.source === null) return // For now, we only support literal strings in the export statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportAllDeclaration (node: TSESTree.ExportAllDeclaration) { if (node.source === null) return // For now, we only support literal strings in the export statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, } } @@ -319,14 +286,12 @@ export class Parser { } } - static registerDependency (importArg: string | null, localDependencies: Set, npmDependencies: Set) { + static registerDependency (importArg: string | null, dependencies: Set) { // TODO: We currently don't support import path aliases, f.ex: `import { Something } from '@services/my-service'` if (!importArg) { // If there's no importArg, don't register a dependency - } else if (importArg.startsWith('/') || importArg.startsWith('./') || importArg.startsWith('../')) { - localDependencies.add(importArg) } else { - npmDependencies.add(importArg) + dependencies.add(importArg) } } }